如何用Adonis.js和Vue.js构建一个票务应用程序

390 阅读11分钟

用Adonis.js和Vue.js构建一个票务应用程序

Adonis.js的编写从一开始就有很强的原则和目标,即成为一个强大的集成系统,具有开发人员的工效、稳定性和速度。

为了展示Adonis.js JavaScript框架的能力,以及它如何与VueWeb框架相结合,本教程将引导你构建一个票务系统应用。

这个应用将创建活动,为活动生成门票,允许用户查看活动和门票,如何进行购买,以及赎回活动门票。

一旦你完成了本教程,你将拥有一个像下面演示的正常运行的票务应用。

前提条件

在继续学习这篇文章之前,你应该有以下经验。

  1. 对TypeScript和Node.js的基本了解
  2. 对Adonis.js的基本了解
  3. 对Vue.js的基本了解

目标

我们将学习如何用Adonis.js 5和Vue.js构建一个票务应用,这样用户就可以找到你周围的活动,注册一个免费的活动,购买门票,在线销售门票,并在全球范围内推广他们的活动。

我们还将介绍在使用Adonis.js作为后端时,用Vue.js构造和构建你的前端的最佳实践。

  • 我们将学习如何用Adonis.js和Vue.js构建一个真实世界的应用程序。
  • 我们将学习如何用Adonis.js和Vue.js处理认证和授权。
  • 我们将学习如何整合Adonis.js和Vue.js。
  • 我们将学习如何在Adonis.js和Vue.js中建立和构造一个票务系统。
  • 我们将学习使用Adonis.js构建面向性能的Web应用程序的最佳实践。

第1步 - 设置Adonis.js 5

设置Adonis.js 5非常容易,因为它需要下面一个简单的npm 命令。

npm init adonis-ts-app adonisjs-ticketing-system-api

创建Adonis.js应用程序后,在项目目录内移动并安装以下软件包以设置API。

cd adonisjs-ticketing-system-api

现在,我们需要设置我们的数据库,以便与新的Adonis.js应用程序连接。

创建数据库

我们的票务系统应用程序需要一个数据库来存储、检索、更新和删除数据。

为了安装数据库,我们需要首先通过使用任何这些数据库客户端来创建数据库,并安装Lucid Object Relational Mapper(ORM)。

npm i @adonisjs/lucid@alpha

安装完ORM后,通过运行下面的命令对其进行设置。

node ace invoke @adonisjs/lucid

运行上述命令后,按照指示,更新你的.env 环境文件。

DB_CONNECTION = mysql
MYSQL_USER = [DB_USER]
MYSQL_HOST = localhost
MYSQL_DB_NAME = [DB_NAME]
MYSQL_PORT = 3306
MYSQL_PASSWORD = [DB_PASSWORD]

如果你在测试你的API时遇到这个错误Client does not support the authentication protocol requested by the server; ,请按照以下步骤来解决。

npm install mysql2

Adonis.js中的认证是通过安装一个包并进行设置的,你可以在这里阅读如何设置认证。

用这个命令安装Auth包。

npm i @adonisjs/auth@alpha

创建迁移和模型

我们的项目将使用总共3个迁移和模型,不包括用户模型和迁移。通过Adonis.js的迁移,你只需编写JavaScript就可以创建/修改数据库。

而模型代表了你的应用程序的数据库层,你可以将你的数据库表描述为JavaScript类,并使用JavaScript方法来读取、写入和删除行。

那么,让我们来创建我们的迁移。

node ace make:migration tickets
node ace make:migration events
node ace make:migration user_events

运行命令后,打开database/migrations/xxxx_tickets.ts 迁移,添加以下代码。

/**
* The Tickets class creates a new Database table with the specified columns
*/
import BaseSchema from '@ioc:Adonis/Lucid/Schema'
export default class Tickets extends BaseSchema {
  protected tableName = 'tickets'

  // The UP method create the table with specified table name and columns specified.
  public async up() {
    this.schema.createTable(this.tableName, (table) => {

      // This create an ID column with primary key attribute
      table.increments('id').primary()

      //This create an CODE column with a String
      table.string('code')
      table.double('amount')

      //This create an USER_ID column with relationship constraints
      table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE')
      table.integer('event_id').unsigned().references('id').inTable('tickets').onDelete('CASCADE')
      table.boolean('is_used').defaultTo(false)
      table.dateTime('used_date').nullable()
      table.timestamps(true)
    })
  }

  // The DOWN method is used to drop the database table.
  public async down() {
    this.schema.dropTable(this.tableName)
  }
}

对我们存储库中的其他迁移重复同样的操作。完成迁移后,你可以用这个命令运行迁移。

node ace migration:run

我们还将创建模型,并通过以下命令映射列。

node ace make:model Event

你可以克隆我的版本库,看看我们是如何映射列的,但这里是一个如何映射事件模型的例子。

/**
 * The Event class is a model that maps the columns of the events table to JS object
 */
import { DateTime } from "luxon";
import {
  BaseModel,
  BelongsTo,
  belongsTo,
  column,
  HasMany,
  hasMany,
} from "@ioc:Adonis/Lucid/Orm";
import Ticket from "./Ticket";
import User from "./User";
export default class Event extends BaseModel {
  // Specifies an ID column with Primary key
  @column({ isPrimary: true })
  public id: number;

  // Specifies a TITLE column
  @column()
  public title: string;

  // Specifies a Description column
  @column()
  public description: string;

  // Specifies a DATE column
  @column()
  public date: string;

  // Specifies a Ticket_Price column
  @column()
  public ticket_price: number;

  // Specifies a USERID column
  @column()
  public userId: number;

  // Specifies a CreatedAT column with auto create attribute
  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime;

  // Specifies a UpdatedAt column with auto create and update attributes
  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime;

  // Specifies a Has Many relationship between Ticket model
  @hasMany(() => Ticket)
  public tickets: HasMany<typeof Ticket>;

  // Indicates that this event belongs to a User
  @belongsTo(() => User)
  public user: BelongsTo<typeof User>;
}

我们将迁移中的列映射到了Event 模型中,还用hasManybelongsTo 方法定义了不同的关系。

创建控制器

在这一步,我们将创建我们的控制器和与我们的票务系统相关的业务逻辑。

根据[Laravel的教程],"控制器作为模型和视图之间的中间人,它处理所有由用户从视图发送的输入"。

我们将首先创建AuthController ,并设置登录和注册流程,如下图所示。

node ace make:controller Auth

app/Controllers/Http/AuthController.ts 中打开该文件,并粘贴下面的代码。

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import User from 'App/Models/User'
export default class AuthController {

  // Our Login method accepts Email and Password before attempting to Login the User

  public async login({ request, auth }: HttpContextContract) {
    const email = request.input('email')
    const password = request.input('password')
    const token = await auth.use('api').attempt(email, password, {
      expiresIn: '10 days',
    })
    return token.toJSON()
  }

  // The register method Creates a New User object and save it to the database.
  public async register({ request, auth }: HttpContextContract) {
    const email = request.input('email')
    const password = request.input('password')
    const name = request.input('name')
    const user = new User()
    user.email = email
    user.password = password
    user.name = name
    await user.save()
    const token = await auth.use('api').login(user, {
      expiresIn: '10 days',
    })
    return token.toJSON()
  }
}

上面的控制器定义了后端API的认证过程。

接下来,我们将创建EventsController ,并粘贴以下代码,而其他控制器可以在资源库中找到。

EventController 是创建所有与管理事件有关的业务逻辑的地方。

让我们把它详细地分解一下。

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Event from 'App/Models/Event'
import User from 'App/Models/User'
import Ticket from 'App/Models/Ticket'
import UserEvent from 'App/Models/UserEvent'
import { DateTime } from 'luxon'
const Keygen = require('keygen')

export default class EventsController {

  // The INDEX method returns all the Events available to be displayed on the homepage
  public async index({}: HttpContextContract) {
    const events = await Event.query().preload('user').preload('tickets')
    return events
  }


  // The SHOW method retrieves a single Event by ID
  public async show({ params }: HttpContextContract) {
    try {
      const event = await Event.find(params.id)
      if (event) {
        await event.preload('user')
        await event.preload('tickets')
        return event
      }
    } catch (error) {
      console.log(error)
    }
  }


  // The UPDATE method updates the detail of an Event
  public async update({ request, params }: HttpContextContract) {
    const event = await Event.find(params.id)
    if (event) {
      event.title = request.input('title')
      event.description = request.input('description')
      event.date = request.input('date')
      event.ticket_price = request.input('ticket_price')
      if (await event.save()) {
        await event.preload('user')
        await event.preload('tickets')
        return event
      }
      return // 422
    }
    return // 401
  }


  // The STORE method is used to creat a new Event
  public async store({ auth, request }: HttpContextContract) {
    const user = await auth.authenticate()
    const event = new Event()
    event.title = request.input('title')
    event.description = request.input('description')
    event.date = DateTime.fromISO(request.input('date')).toSQL()
    event.ticket_price = request.input('ticket_price')
    await user.related('events').save(event)
    return event
  }

  // The DESTROY method deletes a particular event
  public async destroy({ response, auth, params }: HttpContextContract) {
    const user = await auth.authenticate()
    await Event.query().where('user_id', user.id).where('id', params.id).delete()
    return response.redirect('/dashboard')
  }

  // The findEvent method retrieves a single Event by ID without HttpContext
  private async findEvent(id: number): Promise<Event | null> {
    try {
      const event = await Event.find(id)
      if (event) {
        await event.preload('user')
        await event.preload('tickets')
        return event
      }
    } catch (error) {
      console.log(error)
    }
    return null
  }
}

该控制器包括所有可能的CRUD功能,如检索、存储、更新和删除事件。

接下来,我们将添加Join 方法,允许用户使用购买活动门票时发送的代码加入活动。

public async join({
    params,
    auth,
    response,
    request
}: HttpContextContract) {

    // Check if user already join event
    const user = await auth.authenticate()
    const ticket = await Ticket.query()
        .where('user_id', user.id)
        .where('code', request.input('code'))
        .where('event_id', params.id)
        .first()
    if (!ticket) {
        // Throw Ticket not found exception
        return response.json({
            message: 'Ticket code not valid'
        })
    }
    if (
        ticket &&
        ticket.is_used &&
        ticket.used_date <= DateTime.fromSQL(ticket.used_date).toSQL()
    ) {
        // throw Used_ticket_Error
        return response.json({
            message: 'Ticket already used'
        })
    }

    // Create a new User Event to indicate that the Event Ticket has be used
    const joinEvent = new UserEvent()
    joinEvent.user_id = user.id
    joinEvent.event_id = params.id
    ticket.is_used = true
    ticket.used_date = DateTime.now().toSQL()
    if ((await joinEvent.save()) && (await ticket.save())) {
        // Send Success Response
        return response
            .status(200)
            .json({
                message: "You've joined event with id: " + params.id + ' successfully'
            })
    }
    return response.status(500).json({
        message: 'Internal Server Error, Please try again'
    })
}

接下来,我们将包括buy 方法,该方法用于为即将到来的事件购买一张活动门票。

public async buy({
    request,
    params,
    response,
    auth
}: HttpContextContract) {
    // Find Event
    const event = await this.findEvent(params.id)

    if (event === null) {
        return response.status(404).json({
            message: 'Event is not valid'
        })
    }
    const user = await auth.authenticate()

    // Check if price matches
    if (event.ticket_price != request.input('amount')) {
        const message =
            'Ticket with id: ' +
            event.id +
            ' with amount: ' +
            event.ticket_price +
            ' does not equal to User amount: ' +
            request.input('amount')
        return response.status(422).json({
            message
        })
    }

    const ticket = new Ticket()
    ticket.userId = user.id
    ticket.eventId = event.id
    ticket.amount = request.input('amount')
    ticket.code = Keygen.hex(5)

    if (ticket.save()) {
        // Send User Email, Send Code
        await User.find(user.id)
        return response
            .status(200)
            .json({
                message: 'Payment for event with id: ' + event.id + ' was successful'
            })
    }

    return response.status(500).json({
        message: 'Internal Server Error, Please try again'
    })
}

创建端点路由

在这一步,我们将通过在start/routes.ts 文件中添加以下代码来创建这个项目的所有路由。

Route.group(() => {
    Route.group(() => {
        Route.post("register", "AuthController.register");
        Route.post("login", "AuthController.login");
    }).prefix("auth");
    Route.group(() => {
        Route.resource("events", "EventsController").apiOnly();
        Route.resource("tickets", "TicketsController").apiOnly();
        Route.post("events/buy/:id", "EventsController.buy");
        Route.post("events/join/:id", "EventsController.join");
    }).middleware("auth:api");
}).prefix("api/v1");

我们创建了三个不同的路由组,并为每个组分配了不同的前缀,而最后一个组应用了auth 中间件,以确保请求在访问这些端点之前得到验证。

在开发前端之前,你可以通过在浏览器上使用Postman客户端或Hoppscotch立即测试你的API。

Test the API

使用Postman测试API

第2步 - 构建前台

在设置前端时,我们将使用推荐的Vite Web开发构建工具来创建我们的Vue 3项目。

运行以下命令,使用Vite进行安装。

npm init @vitejs/app ticketing-system-vue
cd ticketing-system-vue
npm install
npm run dev

创建路由

接下来,我们将通过在src 文件夹内定义一个新的route.js 文件并添加以下代码,一次性创建本项目中要使用的所有路由。

// src/routes.js
import {
    createRouter,
    createWebHistory
} from "vue-router";

import store from "./store";
import Home from "./views/Home.vue";
import Login from "./views/Login.vue";
import Register from "./views/Register.vue";
import Ticket from "./views/Ticket.vue";
import User from "./views/dashboard/User.vue";
import Admin from "./layouts/Admin.vue";
import Add from "./views/dashboard/Add.vue";
import AdminHome from "./views/dashboard/Admin.vue";

const routes = [{
        path: "/",
        name: "home",
        component: Home,
    },
    {
        path: "/login",
        name: "login",
        component: Login,
    },
    {
        path: "/register",
        name: "register",
        component: Register,
    },

    // Protected Routes here
];
const router = createRouter({
    history: createWebHistory(),
    routes
});

export default router;

在上面的代码中,我们为login,register,index 和下面的adminuser 等受保护的路由创建了不同的路由。

{
    path: "/user/:id",
    name: "user",
    component: User,
    meta: {
        requiresAuth: true
    },
    beforeEnter(to, from, next) {
        if (
            store.getters["isUser"] &&
            parseInt(store.state.user.id) === parseInt(to.params.id)
        ) {
            next();
        } else {
            next({
                name: "login",
            });
        }
    },
}, {
    path: "/admin",
    name: "admin",
    component: Admin,
    meta: {
        requiresAuth: true
    },
    children: [{
            path: "add",
            component: Add,
        },
        {
            path: "/",
            component: AdminHome,
        },
    ],
    beforeEnter(to, from, next) {
        if (store.getters["isAdmin"]) {
            next();
        } else {
            next({
                name: "login",
            });
        }
    },
},

beforeEach 路由钩子检查每个路由是否有任何认证元数据,如果发现,则检查浏览该路由的用户是否已登录,然后重定向。

router.beforeEach((to, from, next) => {
    to.matched.some((record) => {
        console.log(record);
        return record.meta.requiresAuth;
    });
    if (to.matched.some((record) => record.meta.requiresAuth)) {
        if (!store.state.loggedIn) {
            next({
                path: "/login",
                query: {
                    redirect: to.fullPath
                },
            });
        } else {
            next();
        }
    } else {
        next();
    }
});

为了让路由工作,我们添加了新的路由包,并在main.js 文件中注册了这个文件,这样我们就可以把它作为Vue 3插件使用。

npm install vue-router@4

main.js ,我们添加了如下内容。

import { createApp } from "vue";
import router from "./routes";
//........

createApp(App).use(router).use(store).mount("#app");

设置用户认证

我们将首先在src/views 文件夹中创建登录和注册视图,运行以下命令来创建这些文件。

touch login.vue
touch register.vue

接下来,我们将在login.vue 中添加以下代码来创建登录表单,同时创建带有错误处理的登录过程。

我们将在登录文件中添加以下代码。

import { ref, reactive } from "vue";
import { useForm, useField } from "vee-validate";
import { object, string } from "yup";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
export default {
  setup() {
    //creating the Vuex Store object
    const store = useStore();

    // creating th Router Object
    const router = useRouter();

    // creating our validation schema using vee-validate
    const schema = object({
      email: string().required().email(),
      password: string().required().min(8),
    });

    // Using the Validation Schema using vee-validate
    useForm({
      validationSchema: schema,
    });

    // Validating each Field with possible error messages
    const { value: email, errorMessage: emailError } = useField("email");
    const { value: password, errorMessage: passwordError } = useField(
      "password"
    );

    // creating an empty User object
    const user = ref({});

    // Create a Login method
    const login = async () => {
      try {
        await store.dispatch("login", {
          email: email.value,
          password: password.value,
        });
        if (store.state.loggedIn && store.getters["isAdmin"])
          return router.push("/admin/");
        else {
          return router.push({
            name: "user",
            params: { id: store.state.user.id },
          });
        }
      } catch (err) {
        console.log(err);
      }
    };

    // Return all data to the DOM
    return {
      login,
      user,
      emailError,
      passwordError,
      email,
      password,
    };
  },
};

在上面的代码中,HTML模拟生成了一个带有提交按钮的登录表格。我们使用vee-validate 库来处理可能的错误,然后,当提交按钮被点击时,我们在Vuex商店中派发login 动作。

创建商店

接下来,我们将创建我们的Vuex商店,在src 文件夹中创建一个新的store.js 文件并添加以下代码。

touch store.js

你可以查看完整的商店代码库,但下面是我们Vuex商店的快速浏览。

import { createStore } from "vuex";
import Repository from "./repositories/RepositoryFactory";
const EventRepository = Repository.get("events");
const AuthRepository = Repository.get("auth");

const store = createStore({
  state: {
    events: [],
    user: [],
    userevents: [],
    loggedIn: false,
  },

  actions: {
    async getEvents({ commit }) {
      commit("STORE_EVENTS", await EventRepository.get());
    },

    async login({ commit }, payload) {
      commit("STORE_LOGGED_IN_USER", await AuthRepository.login(payload));
    },

    async logout({ commit }) {
      try {
        await AuthRepository.logout();
        commit("STORE_LOGGED_OUT_USER", true);
        return true;
      } catch (error) {
        console.log(error);
      }
      return false;
    },
    async register({ commit }, payload) {
      return await AuthRepository.register(payload);
    },
  },

  mutations: {
    STORE_LOGGED_IN_USER: (state, response) => {
      const { data } = response;
      if (data) {
        localStorage.setItem("token", data.token);
        localStorage.setItem("user", data.user);
        state.user = data.user;
        state.token = data.token;
        state.loggedIn = true;
      }
    },

    STORE_EVENTS: (state, response) => {
      const { data } = response;
      state.events = data;
    },

    STORE_LOGGED_OUT_USER: (state, response) => {
      if (response) {
        localStorage.removeItem("token");
        localStorage.removeItem("user");
        state.user = {};
        state.token = null;
        state.insights = null;
        state.loggedIn = false;
      }
    },
  },

  getters: {
    getEvent: (state) => (id) => {
      return state.events.find((event) => event.id == id);
    },
  },
});

export default store;

商店是我们将操作所有数据的地方,我们从API中检索它们,将它们存储到状态,也从商店中删除或更新它们。

在商店中,我们使用了存储库模式来分离关注点,在与API通信时采用松散耦合。

创建主页

接下来,我们将创建主页,在这里显示来自状态的事件,在src/views 文件夹中创建一个名为Home.vue 的文件,并添加以下代码。

<!--Home.vue-->

<template>
  <div class="text-center banner">
    <div class="container p-5">
      <div class="p-5">
        <h1>Welcome to Ticketing System</h1>
        <p>
          Find events around you, register for free event, buy tickets, sell
          tickets online and promote your event worldwide.
        </p>
      </div>
    </div>
    <div class="container pb-5">
      <Events />
    </div>
  </div>
</template>

<script></script>

以及下面的JavsScript代码。

import Events from "../components/Events.vue";
export default {
  components: {
    Events,
  },
};

脚本通过呼出Events 组件来显示所有的事件。

下面创建了Events 组件。

<template>
  <div class="row pb-5">
    <Event v-for="(event, i) in events" :key="i" :event="event" />
  </div>
</template>
<script></script>

还有,下面的JavaScript。

import Event from "./Event.vue";
import { computed } from "vue";
import { mapState, useStore } from "vuex";
export default {
  name: "Events",
  components: { Event },
  setup() {
    const store = useStore();
    return {
      events: computed(() => store.state.events),
    };
  },
};

显示单个事件

为了查看单个事件,我们需要在routes.js 文件中创建一个路由,并将其与每个事件联系起来。该路由定义如下。

import Ticket from "./views/Ticket.vue";

{
  path: "/events/:id",
  name: "event",
  component: Ticket,
},

路由指向一个Ticket 视图,我们已经在src/views 文件夹中创建了这个视图。

<!--src/views/Ticket.vue-->

<template>
  <div class="text-center banner p-3 pb-5">
    <div class="container pb-5">
      <div class="row pb-5">
        <div class="col-md-8 col-12 pb-5">
          <div class="card card-custom p-5">
            <h5 class="authBtn">{{ event.title }}</h5>
            <hr />
            <div class="container">
              <small class="authBtnInner pb-3">{{ event.description }}</small>
            </div>
          </div>
        </div>
        <div class="col-md-4 col-12">
          <div class="card card-custom p-5">
            <h5 class="authBtn">Event Ticket</h5>
            <hr />
            <span>Event Date:</span>
            <small class="authBtnInner pb-3"> {{ event.date }} </small>
            <hr />
            <span>Event Price:</span>
            <small class="authBtnInner pb-3">${{ event.ticket_price }}</small>
            <router-link to="/" class="btn btn-primary customBtn"
              >Buy Ticket</router-link
            >
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

而JavaScript的代码如下。

import { computed } from "vue";
import { useStore } from "vuex";
import { useRoute } from "vue-router";
export default {
  name: "Ticket",
  setup() {
    const store = useStore();
    const route = useRoute();
    const event = computed(() => store.getters.getEvent(route.params.id));
    return { event };
  },
};

票务组件显示事件信息,包括票价和一个购买按钮,购买后,将向购买者发送一封电子邮件,其中包括票的代码。

测试该项目

下面是我们目前开发的内容的预览。

这是整个代码库的高级抽象,你可以克隆这个项目的不同存储库,仔细看看每个存储库的细节。

总结

在本教程中,我们使用Adonis.js做后台,Vue.js 3做前端,开发了一个票务系统应用。

我们学习了如何用Adonis.js 5创建一个票务系统API,包括认证和授权,我们讨论了如何构建项目,以及如何使用Vue 3和组成API来消费API。