Галерея товаров

На прошлом уроке вы сгенерировали и вывели наши товары на сайте.

Выглядит это пока простенько, поэтому довайте добавим нашим товарам картинки. То есть, напишем галерею товара.

Этот урок доступен бесплатно в рекламных целях, вдруг кто-то почитает и захочет оплатить доступ и к предыдущим?

У 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'
// ...

И наш компонент уже работает - кликайте на GIFку!

Загрузка перетаскиванием работает точно так же.

Действия с файлами

Отлично, файлы загружаются и сортируются - но удалить и отключить мы их не пока можем. Давайте добавим кнопки с действиями под картинки:

<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, сортировкой и разными действиями.

При желании можно добавить выделение файлов и какие-то массовые действиями с ними - все базовые методы для этого прописаны.

Из-за объёма заметки я намеренно не показал разные мелочи, поэтому ищите их в исходниках в нашем репозитории.

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

← Предыдущая заметка
Выводим товары на сайте
Следующая заметка →
Вывод изображений товаров
Комментарии (9)
Игорь
25.06.2022 04:06

Ошибка в тексте - в миграции ProductFile указано final class ProductImages extends Migration, вместо final class ProductFiles extends Migration ... вдруг кто-то не посмотрит исходники репозитория.

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

Поправил, спасибо!

Игорь
25.06.2022 21:54

И еще - в самом уроке ты не упомянул о необходимости внести изменения в 2 файла core/src/Models/File.php и core/src/Models/Product.php . Поэтому, выполняя пошаговые действия, я получил ошибку при загрузке файлов в галлерею и сразу не смог понять, что пропустил сделать или где допустил ошибку))

Пока "экспериментировал", загрузил 10 файлов + все они прописались в таблице app_files базы данных. После редактирования File.php и Product.php все заработало, но ранее загруженные 10 файлов в галереи товара не вывелись. Их удалить и очистить таблицу app_files вручную?

bezumkinВасилий Наумкин
27.06.2022 09:06

ты не упомянул о необходимости внести изменения в 2 файла

Спасибо за замечание, поправил заметку.

Их удалить и очистить таблицу app_files вручную?

Да, конечно.

NightRider
26.06.2022 22:50

Галерея огонь! Можно сказать замена msGallery) Вообще, если проводить аналогии с Modx, то было бы так же интересно рассмотреть что будет использоваться для вывода многоуровневого меню. То, что Eloquent заменяет pdoResources я уже понял, но опять же не хватает примера, как выводить товары из текущей и дочерних категорий. Впрочем наверное я забегаю вперед)

bezumkinВасилий Наумкин
27.06.2022 09:10

что будет использоваться для вывода многоуровневого меню

Посмотри как работают комментарии на этом сайте - они просто отдаются контроллером в плоском виде, а собираются в ветки уже на фронтенде Vue.

Точно так же будет и с меню и со всем остальным. Задача контроллера - выдать нужные данные, а Vue уже всё оформит как пожелаешь.

Eloquent заменяет pdoResources

Скорее, Eloquent заменяет xPDO, а pdoResources просто становится не нужен.

inetloverАлександр Наумов
30.07.2022 14:58

Василий, добрый день!

У меня все работает, только нет загруженного изображения.

Перехожу по ссылке на картинку: 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."

У меня где-то стоит запрет на создание папок и файлов, я так понял.

В логе:

2022/07/30 14:51:57 [error] 1424#1424: *315 FastCGI sent in stderr: "PHP message: PHP Warning: tempnam(): open_basedir restriction in effect. File(/tmp) is not within the allowed path(s): (/home/big-ok/www/vesp-shop.test/) in /home/big-ok/www/vesp-shop.test/core/vendor/league/glide/src/Server.php on line 643PHP message: PHP Warning: file_put_contents(): Filename cannot be empty in /home/big-ok/www/vesp-shop.test/core/vendor/league/glide/src/Server.php on line 645" while reading response header from upstream, client: 127.0.0.1, server: vesp-shop.test, request: "GET /api/image/15?w=250&h=250&fit=crop&t=1659181916000 HTTP/1.1", upstream: "fastcgi://unix:/var/run/php/php7.4-vesp-shop.test.sock:", host: "vesp-shop.test", referrer: "http://192.168.8.106:4000/"

Подскажи, пожалуйста, где, что подкрутить нужно?

Я немного в ступоре от лога, в нем сказано:

/home/big-ok/www/vesp-shop.test/core/vendor/league/glide/src/Server.php on line 645"

Захожу на Гитхаб, что бы посмотреть файл Server.php, а там папки vendor нет.

bezumkinВасилий Наумкин
30.07.2022 17:13

Судя по ошибке, у тебя временная папка для php указана в корне сервера, куда сайт не может получить доступ.

Первый вариант: указать такие настройки php-fpm для сайта:

php_value[upload_tmp_dir] = /home/big-ok/tmp
php_value[sys_temp_dir] = /home/big-ok/tmp

Создать эту директорию и перезапустить процесс.

Второй - просто убрать настройку open_basedir, если сайт крутится не на живом хостинге, а локально.

inetloverАлександр Наумов
30.07.2022 22:58

Василий, спасибо большое!

Первый вариант помог, не было вот этой настройки: php_value[sys_temp_dir].

futuris
Futuris
16.03.2023 17:04
Ок, вижу \core\vendor\vesp\core\src\Controllers Спасибо!
futuris
Futuris
14.03.2023 16:04
Была папка tmp, и удалял и переустанавливал ее - все без толку. Выше товарищ правильно написал, что ...
inetlover
Александр Наумов
22.02.2023 19:10
Спасибо! Да, мне здесь подучиться нужно.
bezumkin
Василий Наумкин
19.02.2023 19:49
Не такая уж тут активность в комментриях, чтобы что-то снижать - а удобнее будет, в первую очередь м...
inetlover
Александр Наумов
19.02.2023 15:12
Спасибо!
inetlover
Александр Наумов
06.02.2023 00:48
Ок, спасибо!
inetlover
Александр Наумов
28.01.2023 18:27
Классно, все работает!
inetlover
Александр Наумов
24.01.2023 18:31
Понял, спасибо!
inetlover
Александр Наумов
16.01.2023 16:41
Понял, спасибо!
bezumkin
Василий Наумкин
14.01.2023 05:16
Да, мне тоже нравится Vite и он по умолчанию используется в Vue 3 и Nuxt 3. Более того, он вроде как...