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

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

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

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

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

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

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

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

← Предыдущая заметка
Выводим товары на сайте
Следующая заметка →
Вывод изображений товаров
Комментарии (21)
bezumkinВасилий Наумкин
25.06.2022 04:09

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

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

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

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

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

Да, конечно.

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

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

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

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

Eloquent заменяет pdoResources

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

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

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

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

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

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

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

bezumkinВасилий Наумкин
10.05.2023 06:43

Всё правильно тебе ответили, смотри логи сервера или включи вывод ошибок на страницу в PHP.

Как иначе угадать, что там за 500 ошибка?

bezumkinВасилий Наумкин
10.05.2023 07:11

Думаю, настройка твоего сервера для вывода ошибок лежит за рамками этого курса.

bezumkinВасилий Наумкин
10.05.2023 08:05

Слушай, ну реально уже не смешно. Я тебе не гугл, чтобы мне все вопросы писать.

На Github лежит рабочий код, на https://shop.vesp.pro крутится рабочий магазин. Если ты что-то опять криво скопировал, или не импортировал используемый класс ResponseInterface - это не ко мне.

Читай внимательно, гугли, проверяй, думай.

bezumkinВасилий Наумкин
10.05.2023 08:36

Строка 10

bezumkinВасилий Наумкин
15.05.2023 06:11

Молодец!

bezumkin
Василий Наумкин
09.04.2024 01:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 05:56
Я просто немного запутался. Когда в абзаце &quot;Vesp/Core&quot; ты пишешь про &quot;новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 18:21
Volledig!
Андрей
14.03.2024 10:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 17:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...