用Adonis.js和Vue.js构建一个票务应用程序
Adonis.js的编写从一开始就有很强的原则和目标,即成为一个强大的集成系统,具有开发人员的工效、稳定性和速度。
为了展示Adonis.js JavaScript框架的能力,以及它如何与VueWeb框架相结合,本教程将引导你构建一个票务系统应用。
这个应用将创建活动,为活动生成门票,允许用户查看活动和门票,如何进行购买,以及赎回活动门票。
一旦你完成了本教程,你将拥有一个像下面演示的正常运行的票务应用。
前提条件
在继续学习这篇文章之前,你应该有以下经验。
- 对TypeScript和Node.js的基本了解
- 对Adonis.js的基本了解
- 对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 模型中,还用hasMany 和belongsTo 方法定义了不同的关系。
创建控制器
在这一步,我们将创建我们的控制器和与我们的票务系统相关的业务逻辑。
根据[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。

使用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 和下面的admin 和user 等受保护的路由创建了不同的路由。
{
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。