В этом руководстве мы создадим приложение для ставок в реальном времени с использованием Vue, Socket IO и Strapi.

Автор: Ян Наляня

Заявка — это предложение купить актив по определенной цене. Цены в основном определяются тем, сколько человек готов заплатить за владение конкретным активом. Процесс торгов обычно наблюдается на аукционах. Недавно этот процесс был оцифрован и представлен на различных сайтах электронной коммерции. Например, eBay, многонациональная компания электронной коммерции, имеет список продуктов в формате аукциона, который состоит из начальной цены, даты окончания листинга и ставок, сделанных для этого листинга. Аукционы обычно принимают ставки на определенный период времени, отсюда и важность даты окончания листинга.

Предпосылки

  • Установка NodeJS
  • Базовые знания Vue
  • Интегрированная среда разработки, я использую Vscode, но вы можете использовать другие.
  • Предварительное знание Strapi полезно, но не обязательно — изучите основы Strapi v4.
  • Базовые знания аутентификации веб-токена JSON.

Введение в Strapi, Socket IO и Vue

Strapi — это безголовая система управления контентом (CMS). Это означает, что он обрабатывает всю логику, необходимую для преобразования и сохранения данных в базе данных, без пользовательского интерфейса. Strapi предоставляет конечные точки API, которые можно использовать для управления данными, хранящимися в базе данных. Кроме того, он обрабатывает аутентификацию и предоставляет множество методов для подключения сторонних систем аутентификации, таких как Google и Facebook Auth. Такие функции сокращают сроки разработки проекта, позволяя разработчикам сосредоточиться на пользовательском интерфейсе, а не на внутренней логике и процессе.

Socket IO — это модуль JavaScript, управляемый событиями, который основан на протоколе WebSocket для обеспечения двунаправленной связи, управляемой событиями. Он легко масштабируется и может использоваться на всех основных платформах. Подключив Socket IO к экземпляру сервера Strapi, мы сможем выполнять все функции управления данными в режиме реального времени, что очень важно в системе онлайн-торгов.

Наконец, мы будем использовать инфраструктуру Vue для получения данных с экземпляра сервера Strapi. Vue поддерживает двустороннюю привязку, которая позволяет обновлять связанные компоненты пользовательского интерфейса в режиме реального времени без ущерба для производительности при обработке объектов DOM.

Инициализация проекта Strapi

Мы собираемся начать с настройки папки нашего проекта, которая будет состоять из экземпляра Strapi (внутренняя часть) и приложения Vue (внешняя часть). Оба из них требуют установки узла.

  1. Выполните следующую команду, чтобы создать каркас сервера Strapi:
npx create-strapi-app@latest backend --quickstart

Strapi v4.2.0 — последняя стабильная версия на момент написания этой статьи.

Команда создаст основу нашей системы, установит необходимые зависимости и инициализирует базу данных SQLite. Вы можете подключить предпочитаемую базу данных, используя следующее руководство. После установки всех зависимостей ваш браузер по умолчанию откроет и отобразит страницу регистрации администратора для Strapi.

  1. Создайте учетную запись администратора, и вы увидите страницу ниже.

Создание коллекций

Коллекция — это структура данных, которая определяет, как наши данные будут представлены в базе данных. Мы будем использовать плагин для создания типов контента, чтобы создать необходимые поля для каждой коллекции.

Коллекция продуктов

Во-первых, создайте коллекцию продуктов, выполнив следующие действия.

  1. Нажмите Конструктор типов контента в разделе «Плагины» на боковой панели навигации.
  2. Нажмите Создать новый тип коллекции.
  3. Введите Product в поле Отображаемое имя и нажмите Продолжить.
  4. Нажмите кнопку Текстовое поле.
  5. Введите name в поле Имя.
  6. Нажмите Добавить другое поле.
  7. Повторите вышеуказанные шаги для следующих полей с соответствующими типами полей.
  • auction_end - Дата и время с типом datetime(ex: 01/01/2022 00:00AM)
  • price - число в формате big integer
  • image - Медиа (несколько медиа)
  • weight - число в формате decimal
  • bid_price - число в формате big integer
  • description - Форматированный текст
  • auction_start - Дата и время с типом datetime(ex: 01/01/2022 00:00AM)
  • available — логическое значение
  1. Нажмите кнопку Сохранить и дождитесь применения изменений.

Мы собираемся использовать поле auction_end для создания обратного отсчета, получая разницу между временем сервера и значением этого поля. Мы выбрали несколько медиафайлов в поле image, потому что будем использовать блок кода карусели bootstrap для демонстрации различных изображений продукта. Наконец, поле price будет содержать базовую цену продукта. bid_price будет суммой значения ставки и текущей цены ставки. Изначально price и bid_price одинаковы, но после создания ставок bid_price должно увеличиваться в режиме реального времени.

Коллекция аккаунтов

Мы собираемся повторить описанные выше процессы, но в контексте учетной записи. Используйте приведенные ниже шаги, чтобы помочь вам.

  1. Нажмите Конструктор типов контента в разделе «Плагины» на боковой панели навигации.
  2. Нажмите Создать новый тип коллекции.
  3. Введите Account в поле Отображаемое имя и нажмите Продолжить.
  4. Нажмите кнопку поля Число.
  5. Введите balance в поле Имя и выберите числовой формат big integer.
  6. Нажмите Добавить другое поле.
  7. Нажмите Связь
  • В диалоговом окне, содержащем Учетная запись в качестве заголовка, введите имя поля как user.
  • В другом диалоговом окне выберите **User(from: user-permissions)** и введите account при вводе имени поля.
  • Выберите параметр, который иллюстрирует два круга, соединенных одной линией, как показано ниже.

Отношение используется для описания двух объектов базы данных, связанных в нашем контексте, таблица учетных записей будет связана с пользовательской таблицей. Отношение — это отношение «один к одному», означающее, что у каждого пользователя будет только одна учетная запись, и учетная запись может быть связана только с одним конкретным пользователем за раз.

Поле баланса будет использоваться для сохранения количества средств, необходимых для покупки продукта. Пользователь сможет делать ставки только на продукты, bid_price которых меньше, чем их текущий аккаунт balance. Проще говоря, вы можете делать ставки только на те продукты, которые можете позволить себе приобрести в системе.

Сбор ставок

  1. Нажмите Конструктор типов контента в разделе «Плагины» на боковой панели навигации.
  2. Нажмите Создать новый тип коллекции.
  3. Введите Bid в поле Отображаемое имя и нажмите Продолжить.
  4. Нажмите кнопку поля Число.
  5. Введите value в поле Имя.
  6. Нажмите Добавить другое поле.
  7. Нажмите Связь
  • В диалоговом окне, содержащем Bid в качестве заголовка, введите имя поля как account.
  • В другом диалоговом окне выберите **Account** и введите bids при вводе имени поля.
  • Выберите параметр, который иллюстрирует один круг справа, соединенный множеством кругов слева, как показано ниже.

В отличие от отношения User-Account, которое мы создали, отношение Bid-Account является отношением "один ко многим". Это означает, что у учетной записи может быть много ставок, и ставка может быть связана только с одной учетной записью за раз.

  1. Нажмите Добавить другое поле.
  2. Нажмите Связь
  • В диалоговом окне, содержащем Bid в качестве заголовка, введите имя поля как account.
  • В другом диалоговом окне выберите **Product** и введите bids при вводе имени поля.
  • Выберите параметр, который иллюстрирует один круг справа, соединенный множеством кругов слева, как показано ниже.

Связь Bid-Product гарантирует, что каждая ставка связана с продуктом. Это позволяет нам отслеживать bid_price продукта. Сумма всех ставок value и price продукта равна bid_price этого продукта.

Расширение службы сбора

После создания и сохранения типов коллекций Strapi изменяет структуру каталогов нашего проекта. Каждый тип коллекции имеет свою папку в папке /src/api, содержащей каталоги route, controller, content-types и services. Мы будем изменять содержимое каталога services для каждой коллекции, чтобы манипулировать данными, связанными с той конкретной коллекцией, которую мы создали.

Что такое коллекторские услуги?

Службы — это многократно используемые функции, которые манипулируют данными, связанными с определенной коллекцией. Их можно сгенерировать с помощью интерактивного интерфейса командной строки Strapi или вручную, создав файл JavaScript в каталоге проекта типа коллекции ./src/api/[collection-type-name]/services/.

Служба сбора аккаунтов

Мы собираемся создать две функции, которые помогут нам запрашивать и добавлять записи, связанные с учетными записями.

./src/api/account/services/account.js
    
    'use strict';
    /**
     * account service.
     */
    const { createCoreService } = require('@strapi/strapi').factories;
    module.exports = createCoreService('api::account.account', ({ strapi }) => ({
        newUser(user_id) {
            return strapi.service('api::account.account').create({ 
            data: { 
                  balance: 0, user: user_id 
                }
            });
        },
        getUserAccount(user_id) {
            return strapi.db.query('api::account.account').findOne({
                where: { user: user_id },
            })
        }
    }));
  • Функция **newUser** позволяет нам связать нового зарегистрированного пользователя с учетной записью. Он принимает один параметр, который является первичным ключом из таблицы User. Параметр user_id используется для создания учетной записи для этого конкретного пользователя. Каждая учетная запись будет иметь начальное значение balance 0.
  • Функция **getUserAccount** позволяет нам получить учетную запись, связанную с конкретным пользователем. Он также принимает user_id в качестве параметра. Затем мы используем функцию Query Engine API Strapi для получения записи. Мы специально используем функцию findOne, поскольку каждая учетная запись связана с одним пользователем.

Служба сбора продуктов

Мы создадим две функции: одну для загрузки ставок, связанных с конкретным продуктом, и другую для обновления цены предложения.

./src/api/product/services/product.js
    
    'use strict';
    /**
     * product service.
     */
    const { createCoreService } = require('@strapi/strapi').factories;
    module.exports = createCoreService('api::product.product', ({ strapi }) => ({
        loadBids(id) {
            return strapi.entityService.findOne('api::product.product', id, {
                fields: "*",
                populate: {
                    bids: {
                        limit: 5,
                        sort: 'createdAt:desc',
                        populate: {
                            account: {
                                fields: ['id'],
                                populate: {
                                    user: {
                                        fields: ['username']
                                    }
                                }
                            }
                        }
                    },
                    image: true
                },
            });
        },
        async findAndUpdateBidPrice(found, price) {
            return strapi.entityService.update('api::product.product', found.id, {
                data: {
                    bid_price: parseInt(found.bid_price) + parseInt(price)
                },
            });
        }
    }));
  • Функция **loadBids** принимает идентификатор продукта в качестве параметра, который затем используется для получения определенного продукта. Мы должны явно указать, какие отношения мы хотим загрузить, это гарантирует, что мы запрашиваем только те атрибуты, которые, как мы уверены, будут использоваться нашим внешним приложением. Мы загрузили изображение продукта, ставки и вложенные отношения под отношением ставки, чтобы мы могли видеть, какой пользователь сделал конкретную ставку. Мы также отсортировали ставки в соответствии с полем createdAt, чтобы самые последние ставки всегда отображались первыми в нашем приложении Vue.
  • **findAndUpdateBidPrice** принимает два параметра. Первый параметр — это продукт, а второй параметр — цена предложения. Мы передаем объект продукта в качестве параметра, потому что нам нужно получить доступ к некоторым полям, содержащимся в объекте. Прежде чем сохранить новую цену bid_price, мы должны убедиться, что предоставленное значение является целым числом. Для этого мы используем parseInt(arg0).

Плагин расширения разрешений пользователя

Плагины являются основой большинства функций, поддерживаемых Strapi. Их можно настраивать и расширять для добавления дополнительных функций или пользовательской логики в зависимости от цели приложения. По умолчанию Strapi поставляется со следующими плагинами:

  1. Контент-менеджер — Используется для создания, просмотра, редактирования и удаления записей, принадлежащих определенной коллекции.
  2. Content Type Builder — используется для управления коллекциями. Мы использовали это ранее для создания наших коллекций.
  3. Электронная почта — обрабатывает все действия по доставке электронной почты.
  4. Медиатека — обрабатывает действия, связанные с загрузкой файлов. Мы будем использовать его для загрузки изображений продукта.
  5. Интернационализация — этот плагин отвечает за преобразование контента на другие указанные языки.
  6. Роли пользователей и разрешения — предоставляет способ аутентификации на сервере Strapi с использованием веб-токенов JSON. Он также определил типы пользователей и действия, которые им разрешено выполнять. До сих пор мы использовали тип пользователя admin для взаимодействия с контентом.

Роли пользователей и переопределение плагина разрешений

Поскольку плагин роли пользователя и разрешения предустановлен в каждом приложении Strapi, нам необходимо получить доступ к его исходному коду из папки node_modules. Конкретная папка, которая нас интересует, это ./node_modules/@strapi/plugin-users-permissions, и мы скопируем функции обратного вызова и регистрации из ./node_modules/@strapi/plugin-users-permissions/server/controllers/auth.js. Чтобы расширить и переопределить эти функции из этого плагина, нам нужно создать strapi-server.js в ./src/extensions/users-permissions.

Скопированных нами функций требуется много, но в основном функция обратного вызова вызывается каждый раз, когда пользователь хочет войти в систему. Если учетные данные действительны, сервер отвечает токеном JWT и некоторой базовой информацией о пользователе. такие как имя пользователя, электронная почта и user_id. В функции обратного вызова непосредственно перед отправкой сведений о пользователе мы вызываем функцию getUserAccount, созданную ранее. Мы добавляем баланс учетной записи к ответу, чтобы мы могли получить к нему доступ позже в нашем приложении Vue.

const user = await getService('providers').connect(provider, ctx.query);
    //Import the account service to fetch account details
    const account = await strapi.service('api::account.account').getUserAccount(user.id);
    ctx.send({
      jwt: getService('jwt').issue({ id: user.id }),
      user: { 
      ...await sanitizeUser(user, ctx), 
      balance: account.balance, account: account.id },
    });

Функция register вызывается каждый раз, когда кто-то хочет создать учетную запись в системе. Как только предоставленные данные действительны, сервер отвечает токеном JWT и основными данными пользователя. Непосредственно перед отправкой ответа мы вызываем функцию newUser. Это гарантирует, что у каждого нового пользователя будет учетная запись сразу после регистрации.

if (!settings.email_confirmation) {
      params.confirmed = true;
    }
    const user = await getService('user').add(params);
    const account = await strapi.service('api::account.account').newUser(user.id);
    const sanitizedUser = await sanitizeUser(user, ctx);
    if (settings.email_confirmation) {
      try {
          await getService('user').sendConfirmationEmail(sanitizedUser);
      } 
      catch (err) {
        throw new ApplicationError(err.message);
      }
      return ctx.send({ 
        user: { 
          ...sanitizedUser, 
          balance: account.balance, 
          account: account.id 
        } });
    }
    const jwt = getService('jwt').issue(_.pick(user, ['id']));
    return ctx.send({
        jwt,
        user: { ...sanitizedUser, balance: account.balance, account: account.id },
    });

The full `./src/extensions/users-permissions/strapi-server.js` can be found on the [backend repo](https://github.com/i1d9/strapi-bids-backend/blob/master/src/extensions/users-permissions/strapi-server.js).


> The register function is called on POST requests made at /api/auth/local/register
> The callback function is called on POST requests made at /api/auth/local/
# Socket IO Initialization

To use the Socket IO module, we need to install it first. Run the command below to install it in the project’s `package.json` file.

```bash
    npm install socket.io
    # OR
    yarn add socket.io

Последняя версия socket.io была 4.5.1, когда я писал эту статью.

Сокет должен быть создан до запуска сервера, поскольку Socket IO прослушивает тот же адрес и номер порта. Откройте ./src/index.js, файл содержит функции, которые запускаются до запуска приложения Strapi. Мы собираемся добавить наш код в функциональный блок bootstrap. Мы указываем объект Cross-Origin Resource Sharing (CORS), чтобы сервер знал, откуда был сделан запрос. Адрес http://localhost:8080 является конечной точкой по умолчанию для приложений Vue в среде разработки.

bootstrap({ strapi }) {
    
    let interval;
    var io = require('socket.io')(strapi.server.httpServer, {
          cors: {
            origin: "http://localhost:8080",
            methods: ["GET", "POST"]
          }
    });
    
    io.on('connection', function (socket) {
      if (interval) clearInterval(interval);
      console.log('User connected');
    
      interval = setInterval(() => io.emit('serverTime', { time: new Date().getTime() }) , 1000);
    
      //Load a Product's Bids
     socket.on('loadBids', async (data) => {
        let params = data;
        try {
        let data = await strapi.service('api::product.product').loadBids(params.id);
        io.emit("loadBids", data);
        } catch (error) {
          console.log(error);
        }
     });
    
      socket.on('disconnect', () => {
            console.log('user disconnected');
            clearInterval(interval);
          });
      });
    
      //Make the socket global
      strapi.io = io
    }

Socket IO — это модуль, управляемый событиями, поэтому мы будем прослушивать события с указанными именами событий. Например, loadBids будет срабатывать, чтобы отвечать ставками, когда клиент отправляет полезную нагрузку, содержащую product_id. Ответ будет сгенерирован ранее созданной функцией службы сбора продуктов.

В блоке событий connection у нас есть функция интервала, которая отправляет серверное время клиенту через каждую секунду (1000 мс). Время сервера будет использоваться для подачи функции обратного отсчета на стороне клиента. Как только сокетное соединение завершается, запускается событие disconnect, которое очищает интервал.

Аутентификация сокета

Поскольку мы используем систему аутентификации Strapi, мы можем проверять токены JWT перед установлением соединения. Мы снова воспользуемся плагином разрешения пользователей для проверки. Недействительные токены будут отклонены, и соединение не будет принято. Мы добавим логин аутентификации перед событием connection.

let interval;
    var io = require('socket.io')(strapi.server.httpServer, {
      cors: {
            origin: "http://localhost:8080",
            methods: ["GET", "POST"]
          }
    });
        
    io.use(async (socket, next) => {
      try {
        //Socket Authentication
        let result = await strapi.plugins[
              'users-permissions'
            ].services.jwt.verify(socket.handshake.query.token);
            //Save the User ID to the socket connection
            socket.user = result.id;
            next();
          } catch (error) {
            console.log(error)
          }
    }).on('connection', function (socket) {});

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

socket.on('makeBid', async (data) => {
     let params = data;
     try {
     //Get a specific product
     let found = await strapi.entityService.findOne('api::product.product', params.product, { fields: "bid_price" });
    
     //Load the user's account
     const account = await strapi.service('api::account.account').getUserAccount(socket.user);
      
      //Check whether user has enough more to make the bid
      if (parseInt(account.balance) >= parseInt(found.bid_price)) {
        //Make new Bid
        await strapi.service('api::bid.bid').makeBid({ ...params, account: account.id });
      //Update the product's bid price
      let product = await strapi.service('api::product.product').findAndUpdateBidPrice(found, params.bidValue);
    
      let updatedProduct = await strapi.service('api::product.product').loadBids(product.id);
      
      //Send the bids including the new one
      io.emit("loadBids", updatedProduct);
        } else {
          console.log("Balance Is low")
        }
    
      } catch (error) {
      console.log(error);
      }
    });

Событие makeBid проверяет, достаточно ли средств на счету пользователя, прежде чем будет создана ставка. Затем мы вызываем сервисные функции из коллекций аккаунта, продукта и ставок.

Создание новых ролей пользователей

Наконец, нам нужно сделать коллекцию продуктов доступной только для аутентифицированных пользователей. Это обеспечит загрузку продуктов при отправке HTTP-запросов GET к конечной точке http://localhost:1337/api/products.

  1. В боковом навигационном меню панели администратора нажмите Настройки.
  2. Нажмите Роли в разделе подключаемого модуля «Пользователи и разрешения».
  3. Нажмите Добавить новую роль.
  4. Введите Bidder в поле имя.
  5. Введите Can create bids and view product listings в поле описание.
  6. В разделе "Разрешения" нажмите Продукты, чтобы отобразить доступные параметры.
  7. Выберите find и findOne, затем нажмите кнопку Сохранить.

  1. Нажмите кнопку Дополнительные настройки в подключаемом модуле пользователей и разрешений, затем выберите Bidder в качестве роли по умолчанию для аутентифицированного пользователя. Это гарантирует, что каждый новый пользователь автоматически становится участником торгов и может просматривать список продуктов, если их токен jwt добавлен к запросу GET для /api/products и /api/products/:id.

Инициализация проекта Vue

Мы собираемся настроить наш внешний генератор приложений Vue, используя следующую команду.

npm install -g @vue/cli
    # OR
    yarn global add @vue/cli

После успешной установки CLI выполните следующую команду, чтобы создать проект.

vue create frontend

Установка вспомогательных зависимостей

Аксиос

Мы будем делать HTTP-запросы с помощью HTTP-клиента Axios. Установите его с помощью следующей команды

npm install axios
    # OR
    yarn add axios

После аутентификации мы будем добавлять наш токен jwt к каждому запросу, отправляемому на сервер с помощью Axios. Если токен успешно проверен, сервер ответит запрошенным нами ресурсом.

Сокет IO-клиент

Мы будем отправлять и получать данные, используя интерфейсную библиотеку Socket IO. Установите его с помощью следующей команды.

npm install socket.io-client
    # OR
    yarn add socket.io-client

Мы будем использовать наш токен jwt для аутентификации наших сокет-соединений. Мы будем использовать те же имена событий, что и при настройке сервера, чтобы мы могли получать и отправлять данные.

Компоненты Vue Router и пользовательского интерфейса

Эта библиотека будет использоваться для сопоставления наших компонентов пользовательского интерфейса с маршрутами. Установите его с помощью приведенной ниже команды.

npm install vue-router
    # OR
    yarn add vue-router

Мы создадим пять компонентов, четыре из которых будут сопоставлены с маршрутами. Не стесняйтесь использовать любой CSS-фреймворк для стилизации ваших компонентов. Я буду использовать bootstrap 5, который вы можете установить с помощью приведенной ниже команды.

npm install bootstrap
    # OR 
    yarn add bootstrap

У нас будет следующая структура проекта, объединяющая связанные компоненты.

Постоянство состояния

У каждого компонента Vue есть свое состояние, однако нам потребуется глобальный доступ к некоторым значениям состояния. В нашем случае нам потребуется доступ к токену jwt из каждого компонента для аутентификации соединений сокетов и каждого запроса, который мы отправляем на серверную часть Strapi. Мы будем использовать vuex, библиотеку управления состоянием для приложений Vue.js, и vuex-persistedstate, подключаемый модуль vuex, который позволяет нам хранить глобальное состояние. в экземпляр локального хранилища браузера. Для начала создайте store.js внутри каталога src.

./src/store.js
    
    import Vuex from 'vuex';
    import axios from 'axios';
    import createPersistedState from "vuex-persistedstate";
    const dataStore = {
        state() {
            return {
                auth: null//The variable we want to access globally
            }
        },
        mutations: {
            setUser(state, user) {
                state.auth = user
            },
            logOut(state) {
                state.auth = null
            }
        },
        getters: {
            getUser(state) {
                return state.auth;
            }, 
            isAuthenticated: state => !!state.auth,
        },
        actions: {
            //Register New Users.
            async Register({ commit }, form) {
                const json = JSON.stringify(form);
                const { data } = await axios
                    .post('http://localhost:1337/api/auth/local/register', json, {
                        headers: {
                            'Content-Type': 'application/json'
                        }
                    });
                //Populate the Auth Object
                await commit('setUser', { ...data.user, token: data.jwt });
            }, 
            //Authenticate a returning user.
            async LogIn({ commit }, form) {
    
                const json = JSON.stringify(form);
                const { data } = await axios
                    .post('http://localhost:1337/api/auth/local/', json, {
                        headers: {
                            'Content-Type': 'application/json'
                        }
                    });
                //Populate the Auth Object
                await commit('setUser', { ...data.user, token: data.jwt });
            }, 
            async LogOut({ commit }) {
                let user = null
                commit('logOut', user);
            }
        }
    }
    export default new Vuex.Store({
        modules: {
            dataStore
        },
        plugins: [createPersistedState()]
    })

Объект dataStore содержит:

  1. Мутации — это функции, которые изменяют наши переменные состояния. Они вызываются функцией фиксации хранилища.
  2. Действия — функции, которые можно использовать для асинхронного вызова коммитов. Мы определили функции регистрации и входа в систему, которые будут отправлять учетные данные пользователя на сервер для проверки. Перед отправкой запроса мы должны убедиться, что отправляемая полезная нагрузка представляет собой строку JSON. Сервер strapi ответит сведениями о пользователе и токеном jwt, который будет сохранен мутацией setUser, вызванной функцией фиксации хранилища.
  3. Состояние — функция, которая определяет все переменные, которые мы хотим сохранить и сохранить.
  4. Геттеры — предоставляет переменные состояния для потребления из сохраненного состояния. Мы будем использовать эти функции для проверки подлинности пользователя перед рендерингом компонента.

Сопоставление компонентов пользовательского интерфейса

Мы будем использовать библиотеку vue-router для рендеринга компонентов, соответствующих определенным маршрутам. Мы также добавим некоторую логику перед маршрутизацией, чтобы убедиться, что только аутентифицированные пользователи могут получить доступ к определенным компонентам пользовательского интерфейса. Для этого мы вызываем функцию получения хранилища isAuthenticated.

****
    ./src/main.js
    import { createApp, h } from 'vue'
    //Bootstrap CSS Framework
    import "bootstrap/dist/css/bootstrap.min.css"
    import App from './App.vue'
    import { createRouter, createWebHashHistory } from "vue-router";
    //Import the state manager
    import store from './store';
    //Product Components
    import ProductList from "./components/Product/List.vue";
    import ProductDetail from "./components/Product/Detail.vue";
    //Authentication Components
    import LoginPage from "./components/Auth/Login.vue";
    import RegisterPage from "./components/Auth/Register.vue";
    
    //Mapping Routes to Components
    const routes = [
        { path: "/", component: ProductList, meta: { requiresAuth: true },},
        { path: "/:id", component: ProductDetail, meta: { requiresAuth: true },},
        { path: "/login", component: LoginPage, meta: { requiresAuth: false },},
        { path: "/register", component: RegisterPage, meta: { requiresAuth: false }},
    ];
    const router = createRouter({
        history: createWebHashHistory(),
        routes,
    });
    router.beforeEach((to, from) => {
      if (to.meta.requiresAuth && !store.getters.isAuthenticated) {
            return {
                path: '/login',
                // save the location we were at to come back later
                query: { redirect: to.fullPath },
            }
        }
    });
    const app = createApp({
        render: () => h(App),
    });
    //Add the router to the Application
    app.use(router);
    //Add the state manager to the Vue Application.
    app.use(store);
    app.mount('#app');
    //Bootstrap JS Helpers
    import "bootstrap/dist/js/bootstrap.js"

Наконец, чтобы наши сопоставленные компоненты отображались, нам нужно изменить основной компонент пользовательского интерфейса Vue, который является родительским компонентом для всех страниц, которые мы собираемся создать. Тег route-view будет отвечать за загрузку компонентов на основе маршрута. Компонент NavBar будет виден во всех компонентах.

./src/App.vue
    <template>
      <NavBar />
      <router-view :key="$route.fullPath"></router-view>
    </template>
    <script>
    import NavBar from "./components/Nav.vue";
    export default {
      name: 'App',
      components: {
        NavBar
      }
    }
    </script>

Компонент входа

Страница входа отображается только при выполнении HTTP-запроса GET на http://localhost:8080/login. Мы используем простую HTML-форму для захвата электронной почты и пароля пользователя, а затем передаем их созданному нами действию LogIn. Пользователь будет перенаправлен на http://localhost:8080/, если учетные данные верны.

./src/components/Auth/Login.vue
    <template>
    <div class="container my-5">
    
    <form @submit.prevent="submit" class="d-flex flex-column">
    <div class="mb-3">
    <input type="email" class="form-control" placeholder="Email Address" name="email" v-model="form.email" />
    </div>
    
    <div class="mb-3">
    <input class="form-control" type="password" name="password" v-model="form.password" placeholder="Password" />
    </div>
    
    <button type="submit" class="btn btn-success m-1">Login</button>
    
    <router-link to="/register" class="btn btn-outline-primary m-1">Register</router-link>
    
    </form>
    <p v-if="showError" id="error">Invalid Email/Password</p>
    </div>
    </template>
    <script>
    import { mapActions } from "vuex";
    export default {
        name: " LoginPage",
        data() {
            return {
                form: {
                    email: "",
                    password: "",
                },
                showError: false
            }
        },
        methods: {
            ...mapActions(["LogIn"]),
            async submit() {
                try {
                    await this.LogIn({
                        identifier: this.form.email,
                        password: this.form.password
                    });
                    this.$router.push(this.$route.query.redirect)
                    this.showError = false
                } catch (error) {
                    this.showError = true
                }
            }
        }
    }
    </script>

Мы добавили несколько загрузочных стилей CSS, чтобы сделать страницу адаптивной для мобильных устройств. Кнопка регистрации направляет пользователя на страницу регистрации по адресу http://localhost:8080/register.

Зарегистрировать компонент

Страница регистрации используется для создания новых учетных записей. По умолчанию все пользователи [Bidders](https://www.dropbox.com/scl/fi/jrha49e6tg0hjx1xx4csp/How-to-create-a-real-time-Bidding-App-using-Strapi-v4-Vue-and-Socket-IO.paper?dl=0&rlkey=di2crkfkq9ckjsafeadke6obw#:uid=540338709510983306879551&h2=Creating-new-user-roles) и все они имеют денежный счет. Если сервер успешно создает учетную запись аутентификации, пользователю автоматически назначается денежный счет.

./src/components/Auth/Register.vue
    <template>
    <div class="container my-5">
    <form @submit.prevent="submit" class="d-flex flex-column">
    <div class="mb-3">
    <input type="text" class="form-control" placeholder="Username" name="username" v-model="form.username" />
    </div>
    
    <div class="mb-3">
    <input type="email" class="form-control" placeholder="Email Address" name="email"
    v-model="form.email" />
    </div>
    
    <div class="mb-3">
    <input type="password" class="form-control" name="password" v-model="form.password" placeholder="Password" />
    </div>
    
    <button type="submit" class="btn btn-success m-1">Create Account</button>
    <router-link to="/login" class="btn btn-outline-primary m-1">Login</router-link>
    
    </form>
    <p v-if="showError" id="error">
    Could not create an account with the details provided
    </p>
    
    </div>
    </template>
    <script>
    import { mapActions } from "vuex";
    export default {
        name: " RegisterPage",
        data() {
            return {
                form: {
                    username: "",
                    email: "",
                    password: "",
                },
                showError: false
            }
        },
        methods: {
            ...mapActions(["Register"]),
            async submit() {
                try {
                    await this.Register({
                        username: this.form.username,
                        password: this.form.password,
                        email: this.form.email,
                    });
                    this.$router.push("/");
                    this.showError = false
                } catch (error) {
                    this.showError = true
                }
            }
        }
    }
    </script>

Компонент карточки продукта

Этот компонент форматирует сведения о продукте в стиле Bootstrap. Детали передаются в компоненты через реквизиты. Кроме того, компонент отображает время, оставшееся до окончания аукциона (поле auction_end, которое мы определили в типе коллекции товаров). После того, как компонент был создан, мы инициировали функцию интервала, которая вычисляет значения обратного отсчета на основе реквизита serverTime, значение которого извлекается в реальном времени из экземпляра сокета io. Этот компонент будет отображаться для всех доступных продуктов в компоненте Listing.

./src/components/Product/Card.vue
    <template>
    <div class="card m-3 p-3 position-relative">
    <img :src="'http://localhost:1337' + this.product.image.data[0].attributes.formats.medium.url" class="card-img-top" alt=/>
    
    <div class="card-body">
    <router-link :to="'/' + this.id">{{ content.name }}</router-link>
    <p>KES {{ content.price }}</p>
    <h6 class="card-subtitle mb-2 text-muted">{{ countDownValue }} </h6>
    </div>
    <div class="position-absolute top-0 end-0 p-1 m-2 btn-outline-danger">
      Live
    </div>
    </div>
    </template>
    <script>
    export default {
      name: "CardComponent",
      props: ['product', 'serverTime', 'id'],
      data() {
        return {
                content: this.product,
                countDownInterval: null,
                countDownValue: ''
        }
      },
      methods: {
        countDown() {
         // Get today's date and time
         var now = this.serverTime;
         // Find the distance between now and the count down date
         var distance = new Date(this.product.auction_end) - now;
         // Time calculations for days, hours, minutes and seconds
         var days = Math.floor(distance / (1000 * 60 * 60 * 24));
         var hours = Math.floor((distance%(1000 *60 * 60 * 24))/(1000 * 60 * 60));
         var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
         var seconds = Math.floor((distance % (1000 * 60)) / 1000);
        this.countDownValue=`${days} Days ${hours} hrs ${minutes} minutes ${seconds} seconds`;
            }
        },
        created() {
            this.countDownInterval = setInterval(() => this.countDown() , 1000);
        }
    }
    </script>

Компонент листинга

Этот компонент извлекает продукты с сервера Strapi с помощью обычного HTTP-запроса GET, к которому в заголовке добавляется токен jwt. Мы указали адрес клиента (http://localhost:8080) на сервере для предотвращения ошибок CORS. После загрузки продуктов мы используем экземпляр Socket IO для получения серверного времени, которое будет использоваться для вычисления обратного отсчета каждого продукта. Время сервера и сведения о продукте будут переданы компоненту карты в качестве реквизита.

./src/components/Product/List.vue
    <template>
        <div class="container p-2">
            <div class="row">
                <div class="col-lg-3 col-md-4" v-for="product in products" :key="product.id">
                    <Card :product="product.attributes" :serverTime="serverTime" :id="product.id" />
                </div>
            </div>
        </div>
    </template>
    <script>
    import axios from 'axios';
    import socketIOClient from "socket.io-client";
    import Card from './Card.vue';
    export default {
        name: "ProductList",
        data() {
            return {
                products: [],
                socket: socketIOClient("http://localhost:1337", {
                    query: {
                        token: this.$store.getters.getUser.token
                    }
                }),
                serverTime: null,
            };
        },
        methods: {
         async getproducts() {
         try {
          const response = await axios.get("http://localhost:1337/api/products?populate=image&&name",{
                headers: {
                    'Authorization': `Bearer ${this.$store.getters.getUser.token}`
                    }
                    });
                    this.products = response.data.data;
                }
                catch (error) {
                    console.log(error);
                }
            }
        },
        created() {
            this.getproducts();
            this.socket.on("serverTime", (data) => { 
                this.serverTime = data.time
            });
        },
        components: { Card }
    }
    </script>

Не стесняйтесь добавлять продукты из панели администратора. Поэкспериментируйте с полем auction_end, чтобы увидеть, как меняется обратный отсчет.

Компонент сведений о продукте

Этот компонент загружает ставки через аутентифицированное соединение сокета. Он также фиксирует значение ставки пользователя и отправляет его на сервер. Затем ставка транслируется всем клиентам, просматривающим один и тот же продукт. Мы также используем время сервера для вычисления времени обратного отсчета.

<template>
    <div class="container mt-4 m-md-3 m-lg-3 ">
    <div class="row">
    <div class="col-md-7 col-lg-7">
    <div id="carouselExampleSlidesOnly" class="carousel slide" data-bs-ride="carousel">
    <div class="carousel-inner">
    <div v-for="image in this.product.image" :key="image.id">
    <img :src="'http://localhost:1337' + (image.formats.medium.url)" class="card-img-top" alt=/></div>
    <div class="carousel-item">
    <img src="" class="d-block w-100" alt=""></div>
    <div class="carousel-item"><img src="" class="d-block w-100" alt="">
    </div></div></div>
    <p>{{ this.product.description }}</p></div>
    <div class="col-md-5 col-lg-5"> Time left: {{ countDown(this.serverTime) }}
    <div class="card m-2 p-3">
    <p>Current Price: KES {{ this.product.bid_price }} </p>
    <div class="overflow-auto" style="height: 10rem;">
      <div v-if="this.bids.length > 0">
        <div v-for="bid in bids" :key="bid.id">
          <div class="border p-3 m-2">
          <p>{{ bid.account.user.username }}</p>
          <p>KES {{ bid.value }}</p>
          </div>
        </div>
      </div>
      <div class="card text-center m-2 p-3" v-else>
      <span>No Bids available</span>
      </div>
    </div>
    </div>
    <div class="m-2 d-flex flex-column">
    <input type="number" v-model="bidValue" placeholder="Bid Value" class="form-control" min="1" />
    <button type="button" @click="makeBid" class="btn btn-outline-warning">Bid</button>
    </div>
    </div>
    </div>
    </div>
    </template>
    <script>
    import socketIOClient from "socket.io-client";
    export default {
        name: "ProductDetail",
        data() {
            return {
                bidValue: 1,
                product: {
                },
                bids: [],
                socket: socketIOClient("http://localhost:1337", {
                    query: {
                        token: this.$store.getters.getUser.token
                    }
                }),
                serverTime: new Date(),
                countDownInterval: null,
                countDownValue: ''
            }
        },
        methods: {
         countDown(now) {
         // Find the distance between now and the count down date
         var distance = new Date(this.product.auction_end) - now;
         // Time calculations for days, hours, minutes and seconds
         var days = Math.floor(distance / (1000 * 60 * 60 * 24));
      var hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
        var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
        var seconds = Math.floor((distance % (1000 * 60)) / 1000);
        return `${days} Days ${hours} hrs ${minutes} minutes ${seconds} seconds`
            },
            makeBid() {
              if (this.bidValue > 0) {
              this.socket.emit("makeBid", { bidValue: this.bidValue, user: this.$store.getters.getUser.id, product: this.product.id });
                }
            }
        },
        created() {
            this.socket.emit("loadBids", { id: this.$route.params.id });
            this.socket.on("loadBids", (data) => {
                this.product = data;
                this.bids = data.bids;
            });
        },
        mounted() {
            this.socket.on("serverTime", (data) => this.serverTime = data.time);
        }
    }
    </script>

Мы просматриваем все доступные изображения продуктов, чтобы их можно было отобразить в карусели начальной загрузки. Описание продукта отображается под элементами карусели, а все ставки и оставшееся время отображаются в правой части изображения. Пользовательский интерфейс можно настроить по своему вкусу. Каждая ставка отображает имя пользователя, сделавшего ставку, и стоимость ставки. Сумма всех ставок добавляется к базовой цене, чтобы получить bid_price

Заключение

В этой статье вы узнаете, как создать систему торгов в реальном времени с Vue.js в качестве интерфейса и Strapi в качестве сервера. Мы создали полнодуплексный канал связи с помощью клиентской и серверной библиотеки socket io для манипулирования данными и их трансляции конкретным прослушивателям событий. В статье также показано, как расширять плагины Strapi и создавать собственные службы сбора.

Вы можете скачать исходный код из следующих репозиториев:

  1. Вью Фронтенд
  2. Страпи Бэкенд

Посмотрите демонстрацию приложения здесь.