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

На прошлом уроке вы сгенерировали и вывели наши товары на сайте.
Выглядит это пока простенько, поэтому довайте добавим нашим товарам картинки. То есть, напишем галерею товара.
Этот урок доступен бесплатно в рекламных целях, вдруг кто-то почитает и захочет оплатить доступ и к предыдущим?
У 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 комментарий

Ошибка в тексте - в миграции ProductFile указано final class ProductImages extends Migration, вместо final class ProductFiles extends Migration... вдруг кто-то не посмотрит исходники репозитория.
Василий Наумкин
Поправил, спасибо!
И еще - в самом уроке ты не упомянул о необходимости внести изменения в 2 файла core/src/Models/File.php и core/src/Models/Product.php. Поэтому, выполняя пошаговые действия, я получил ошибку при загрузке файлов в галлерею и сразу не смог понять, что пропустил сделать или где допустил ошибку))
Пока "экспериментировал", загрузил 10 файлов + все они прописались в таблице app_files базы данных. После редактирования File.php и Product.php все заработало, но ранее загруженные 10 файлов в галереи товара не вывелись. Их удалить и очистить таблицу app_files вручную?
Василий Наумкин
ты не упомянул о необходимости внести изменения в 2 файла
Спасибо за замечание, поправил заметку.
Их удалить и очистить таблицу app_files вручную?
Да, конечно.
Галерея огонь! Можно сказать замена msGallery) Вообще, если проводить аналогии с Modx, то было бы так же интересно рассмотреть что будет использоваться для вывода многоуровневого меню. То, что Eloquent заменяет pdoResources я уже понял, но опять же не хватает примера, как выводить товары из текущей и дочерних категорий. Впрочем наверное я забегаю вперед)
Василий Наумкин
что будет использоваться для вывода многоуровневого меню
Посмотри как работают комментарии на этом сайте - они просто отдаются контроллером в плоском виде, а собираются в ветки уже на фронтенде Vue.
Точно так же будет и с меню и со всем остальным. Задача контроллера - выдать нужные данные, а Vue уже всё оформит как пожелаешь.
Eloquent заменяет pdoResources
Скорее, Eloquent заменяет xPDO, а pdoResources просто становится не нужен.
Александр Наумов
Василий, добрый день!
У меня все работает, только нет загруженного изображения.
Перехожу по ссылке на картинку: 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 нет.
Василий Наумкин
Судя по ошибке, у тебя временная папка для php указана в корне сервера, куда сайт не может получить доступ.
Первый вариант: указать такие настройки php-fpm для сайта:
php_value[upload_tmp_dir] = /home/big-ok/tmp
php_value[sys_temp_dir] = /home/big-ok/tmp
Создать эту директорию и перезапустить процесс.
Второй - просто убрать настройку open_basedir, если сайт крутится не на живом хостинге, а локально.
Александр Наумов
Василий, спасибо большое!
Первый вариант помог, не было вот этой настройки: php_value[sys_temp_dir].
Получилась только первая часть урока, до "Компонент галереи". В админке, на "странице Товаров" появились иконки изображений, при нажатии на которые всплывает модал с сообщением. Ну т.е. как и должно быть:
После того, как проделал остальные операции, при нажатии на иконку, появляется окно с кнопкой "Загрузка" и сразу (еще до нажатия на кнопку) выскакивает "Ошибка сервера 500":
Пробовал смотреть логи сервера, но формируются только access‑логи, логов с ошибками нет. Обращался к хостерам (сайт висит на VPS с Ubuntu 20.04), чтобы посмотрели не на их ли стороне ошибка. Они ответили:
В error логах информации никакой при проверке не появляется. Скорее всего в Вашем веб-приложении отключен debug режим и из-за это никакой дополнительной информации по ошибке не выводится. Поскольку мы не знаем как работает Ваше приложение - мы не можем сейчас сообщить из-за чего данная проблема. Обратитесь к Вашему разработчику для включения указанного ранее debug режима, после чего мы сможем проверить ещё раз и если будет дополнительная информация - проверим, что можно сделать.
Василий Наумкин
Всё правильно тебе ответили, смотри логи сервера или включи вывод ошибок на страницу в PHP.
Как иначе угадать, что там за 500 ошибка?
Это в php.ini ?
Василий Наумкин
Думаю, настройка твоего сервера для вывода ошибок лежит за рамками этого курса.
да, конечно. Буду разбираться. Спасибо
В итоге получили такую ошибку:
2023/05/10 10:42:33 [error] 755505#755505: *116662 FastCGI sent in stderr: "PHP message: PHP Fatal error: Could not check compatibility between App\Controllers\Admin\Product\Files::put(): App\Controllers\Admin\Product\ResponseInterface and Vesp\Controllers\ModelController::put(): Psr\Http\Message\ResponseInterface, because class App\Controllers\Admin\Product\ResponseInterface is not available in /var/www/creonika/data/www/vespshop/core/src/Controllers/Admin/Product/Files.php on line 65" while reading response header from upstream, client: 195.218.187.18, server: vesp.my-site.ru, request: "GET /api/admin/product/9/files HTTP/1.1", upstream: "fastcgi://127.0.0.1:10005", host: "vesp.my-site.ru", referrer: "https://vesp.my-site.ru/admin/products/files/9
Василий Наумкин
Слушай, ну реально уже не смешно. Я тебе не гугл, чтобы мне все вопросы писать.
На Github лежит рабочий код, на https://shop.vesp.pro крутится рабочий магазин. Если ты что-то опять криво скопировал, или не импортировал используемый класс ResponseInterface - это не ко мне.
Читай внимательно, гугли, проверяй, думай.
Извини. Мне пока сложно в этом массиве информации вычленить что и почему происходит. Спасибо за наводку! В этот раз вроде вперед не забегал и ничего лишнего не делал. И понять не могу, как я пропустил в уроках ранее импорт класса ResponseInterface, вроде по инструкции все делал. Буду разбираться, смотреть исходники и перечитывать.
Василий Наумкин
Строка 10
Слегка продвинулся. Прошелся по всем файлам, упоминавшимся в уроке, проверил, скопировал с Github. В итоге слегка продвинулся. Ошибка, которая появлялась сразу при нажатии на иконку картинки и появлении модала исчезла. Теперь модал открывается без ошибок и него появилась рамка.
Но при попытке загрузить фото, выскакивает ошибка 500 и фото не грузятся.
Return value of App\\Models\\Product::productFiles() must be an instance of App\\Models\\HasMany, instance of Illuminate\\Database\\Eloquent\\Relations\\HasMany returned
Разобрался с ошибкой. Блин, конечно, в самом тексте ошибки указано было, что нужно добавить в файл
vesp-shop/core/src/Models/Product.php
. Грузятся теперь фотки.
Василий Наумкин
Молодец!
bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Futuris
04.04.2024, 08:56:12
Я просто немного запутался. Когда в абзаце "Vesp/Core" ты пишешь про "новый trait FileModel", я поду...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!