介绍
在上一教程中,您为发票应用程序构建了后端服务器。在本教程中,您将构建用户将与之交互的应用程序部分,即用户界面。
注意:这是 3 部分系列的第 2 部分。第一个教程是How To Build a Lightweight Invoicing App with Node: Database and API。第三个教程是如何使用 Vue 和 Node 构建轻量级发票应用:JWT 身份验证和发送发票。
本教程中的用户界面将使用Vue构建,并允许用户登录以查看和创建发票。
先决条件
要完成本教程,您需要:
- Node.js 安装在本地,您可以按照如何安装 Node.js 和创建本地开发环境来完成。
本教程已通过 Node v16.1.0、npm
v7.12.1、Vue v2.6.11、Vue Router v3.2.0、axios
v0.21.1 和 Bootstrap v5.0.1验证。
步骤 1 — 设置项目
您可以使用@vue/cli
来创建一个新的 Vue.js 项目。
注意:您应该能够将此新项目目录放置invoicing-app
在您在上一教程中创建的目录旁边。这引入了分离server
和的常见做法client
。
在终端窗口中,使用以下命令:
- npx @vue/cli create --inlinePreset='{ "useConfigFiles": false, "plugins": { "@vue/cli-plugin-babel": {}, "@vue/cli-plugin-eslint": { "config": "base", "lintOn": ["save"] } }, "router": true, "routerHistoryMode": true }' invoicing-app-frontend
这将使用内联预设配置通过Vue Router创建 Vue.js 项目。
导航到新创建的项目目录:
- cd invoicing-app-frontend
启动项目以验证没有错误。
- npm run serve
如果您localhost:8080
在 Web 浏览器中访问本地应用程序(通常位于),您将看到一条"Welcome to Your Vue.js App"
消息。
这将创建Vue
我们将在本文中构建的示例项目。
对于此发票应用程序的前端,将向后端服务器发出大量请求。
为了实现这一点,我们将使用axios。要安装axios
,请在项目目录中运行命令:
- npm install axios@0.21.1
要在应用程序中允许一些默认样式,您将使用Bootstrap
.
首先,public/index.html
在代码编辑器中打开文件。
将用于 Bootstrap 的 CDN 托管的 CSS 文件添加到head
文档的 :
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x" crossorigin="anonymous">
将用于 Popper 和 Bootstrap 的 CDN 托管的 JavaScript 文件添加到head
文档的 :
<script src="https://cdn.jsdelivr.net/npm/@popperjs/[email protected]/dist/umd/popper.min.js" integrity="sha384-IQsoLXl5PILFhosVNubq5LC7Qb9DXgDA9i+tQ8Zj3iwWAwPtgFTxbJ8NT4GN1R8p" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.min.js" integrity="sha384-Atwg2Pkwv9vp0ygtn1JAojH0nYbwNJLPhwyoVbhoPwBhjQPR5VtM2+xf0Uwh9KtT" crossorigin="anonymous"></script>
您可以App.vue
使用以下代码行替换 的内容:
<template>
<div id="app">
<router-view/>
</div>
</template>
你可以忽略或删除src/views/Home.vue
,src/views/About.vue
以及src/components/HelloWorld.vue
自动生成的文件。
此时,您有了一个带有 Axios 和 Bootstrap 的新 Vue 项目。
第 2 步 – 配置 Vue Router
对于此应用程序,您将有两条主要路线:
/
呈现登录页面/dashboard
呈现用户仪表板
要配置这些路由,请打开src/router/index.js
并使用以下代码行更新它:
import Vue from 'vue'
import VueRouter from 'vue-router'
import SignUp from '@/components/SignUp'
import Dashboard from '@/components/Dashboard'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'SignUp',
component: SignUp
},
{
path: '/dashboard',
name: 'Dashboard',
component: Dashboard
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
这指定了当用户访问您的应用程序时应该向用户显示的组件。
第 3 步 – 创建组件
组件使您的应用程序的前端更加模块化和可重用。此应用程序将具有以下组件:
- 标题
- 导航
- 注册(并登录)
- 仪表盘
- 创建发票
- 查看发票
创建标题组件
该Header
组件显示应用程序的名称以及Navigation
用户是否已登录。
Header.vue
在src/components
目录中创建一个文件。组件文件有以下几行代码:
<template>
<nav class="navbar navbar-light bg-light">
<div class="navbar-brand m-0 p-3 h1 align-self-start">{{title}}</div>
<template v-if="user != null">
<Navigation v-bind:name="user.name" v-bind:company="user.company_name"/>
</template>
</nav>
</template>
<script>
import Navigation from './Navigation'
export default {
name: "Header",
props : ["user"],
components: {
Navigation
},
data() {
return {
title: "Invoicing App",
};
}
};
</script>
Header 组件有一个prop
名为user
. 这prop
将由将使用标头组件的任何组件传递。在标题的模板中,Navigation
组件被导入并使用条件渲染来确定是否Navigation
应该显示。
创建导航组件
该Navigation
组件是包含不同操作链接的侧边栏。
Navigation.vue
在/src/components
目录中创建一个新组件。该组件具有以下模板:
<template>
<div class="flex-grow-1">
<div class="navbar navbar-expand-lg">
<ul class="navbar-nav flex-grow-1 flex-row">
<li class="nav-item">
<a class="nav-link" v-on:click="setActive('create')">Create Invoice</a>
</li>
<li class="nav-item">
<a class="nav-link" v-on:click="setActive('view')">View Invoices</a>
</li>
</ul>
</div>
<div class="navbar-text"><em>Company: {{ company }}</em></div>
<div class="navbar-text h3">Welcome, {{ name }}</div>
</div>
</template>
...
接下来,Navigation.vue
在代码编辑器中打开文件并添加以下代码行:
...
<script>
export default {
name: "Navigation",
props: ["name", "company"],
methods: {
setActive(option) {
this.$parent.$parent.isactive = option;
},
}
};
</script>
创建的组件有两个props
:用户名和公司名。当用户单击导航链接时,该setActive
方法将更新调用组件父级的Navigation
组件,在本例中Dashboard
为 。
创建注册组件
该SignUp
组件包含注册和登录表单。在/src/components
目录中创建一个新文件。
首先,创建组件:
<template>
<div class="container">
<Header/>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="login-tab" data-bs-toggle="tab" data-bs-target="#login" type="button" role="tab" aria-controls="login" aria-selected="true">Login</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="register-tab" data-bs-toggle="tab" data-bs-target="#register" type="button" role="tab" aria-controls="register" aria-selected="false">Register</button>
</li>
</ul>
<div class="tab-content p-3">
...
</div>
</div>
</template>
<script>
import axios from "axios"
import Header from "./Header"
export default {
name: "SignUp",
components: {
Header
},
data() {
return {
model: {
name: "",
email: "",
password: "",
c_password: "",
company_name: ""
},
loading: "",
status: ""
};
},
methods: {
...
}
}
</script>
该Header
组件被导入,并且也指定的组件的数据属性。
接下来,创建方法来处理提交数据时发生的情况:
...
methods: {
validate() {
// checks to ensure passwords match
if (this.model.password != this.model.c_password) {
return false;
}
return true;
},
...
}
...
该validate()
方法执行检查以确保用户发送的数据符合我们的要求。
...
methods: {
...
register() {
const formData = new FormData();
let valid = this.validate();
if (valid) {
formData.append("name", this.model.name);
formData.append("email", this.model.email);
formData.append("company_name", this.model.company_name);
formData.append("password", this.model.password);
this.loading = "Registering you, please wait";
// Post to server
axios.post("http://localhost:3128/register", formData).then(res => {
// Post a status message
this.loading = "";
if (res.data.status == true) {
// now send the user to the next route
this.$router.push({
name: "Dashboard",
params: { user: res.data.user }
});
} else {
this.status = res.data.message;
}
});
} else {
alert("Passwords do not match");
}
},
...
}
...
register
当用户尝试注册新帐户时,组件的方法会处理该操作。首先,使用该validate
方法验证数据。然后,如果满足所有条件,则准备使用formData
.
我们还定义了loading
组件的属性,让用户知道他们的表单何时被处理。最后,使用 将POST
请求发送到后端服务器axios
。当从服务器收到状态为 的响应时true
,用户将被定向到仪表板。否则,会向用户显示错误消息。
...
methods: {
...
login() {
const formData = new FormData();
formData.append("email", this.model.email);
formData.append("password", this.model.password);
this.loading = "Logging In";
// Post to server
axios.post("http://localhost:3128/login", formData).then(res => {
// Post a status message
this.loading = "";
if (res.data.status == true) {
// now send the user to the next route
this.$router.push({
name: "Dashboard",
params: { user: res.data.user }
});
} else {
this.status = res.data.message;
}
});
}
}
...
该login
方法类似于register
方法。数据准备好并发送到后端服务器以验证用户。如果用户存在且详细信息匹配,则将用户定向到其仪表板。
现在,看一下注册模板:
<template>
<div class="container">
...
<div class="tab-content p-3">
<div id="login" class="tab-pane fade show active" role="tabpanel" aria-labelledby="login-tab">
<div class="row">
<div class="col-md-12">
<form @submit.prevent="login">
<div class="form-group mb-3">
<label for="login-email" class="label-form">Email:</label>
<input id="login-email" type="email" required class="form-control" placeholder="[email protected]" v-model="model.email">
</div>
<div class="form-group mb-3">
<label for="login-password" class="label-form">Password:</label>
<input id="login-password" type="password" required class="form-control" placeholder="Password" v-model="model.password">
</div>
<div class="form-group">
<button class="btn btn-primary">Log In</button>
{{ loading }}
{{ status }}
</div>
</form>
</div>
</div>
</div>
...
</div>
</div>
</template>
登录表单如上所示,输入字段链接到创建组件时指定的相应数据属性。当表单的提交按钮被点击时,login
组件的方法被调用。
通常,当表单的提交按钮被点击时,表单是通过一个GET
或POST
请求提交的。我们没有使用它,而是<form @submit.prevent="login">
在创建表单时添加以覆盖默认行为并指定应调用登录函数。
注册表也是这样的:
<template>
<div class="container">
...
<div class="tab-content p-3">
...
<div id="register" class="tab-pane fade" role="tabpanel" aria-labelledby="register-tab">
<div class="row">
<div class="col-md-12">
<form @submit.prevent="register">
<div class="form-group mb-3">
<label for="register-name" class="label-form">Name:</label>
<input id="register-name" type="text" required class="form-control" placeholder="Full Name" v-model="model.name">
</div>
<div class="form-group mb-3">
<label for="register-email" class="label-form">Email:</label>
<input id="register-email" type="email" required class="form-control" placeholder="[email protected]" v-model="model.email">
</div>
<div class="form-group mb-3">
<label for="register-company" class="label-form">Company Name:</label>
<input id="register-company" type="text" required class="form-control" placeholder="Company Name" v-model="model.company_name">
</div>
<div class="form-group mb-3">
<label for="register-password" class="label-form">Password:</label>
<input id="register-password" type="password" required class="form-control" placeholder="Password" v-model="model.password">
</div>
<div class="form-group mb-3">
<label for="register-confirm" class="label-form">Confirm Password:</label>
<input id="register-confirm" type="password" required class="form-control" placeholder="Confirm Password" v-model="model.c_password">
</div>
<div class="form-group mb-3">
<button class="btn btn-primary">Register</button>
{{ loading }}
{{ status }}
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
在@submit.prevent
这里还用于调用register
方法时按钮被点击提交。
现在,使用以下命令运行您的开发服务器:
- npm run serve
localhost:8080
在浏览器中访问以观察新创建的登录和注册页面。
注意:在试验用户界面时,您需要invoicing-app
运行服务器。此外,您可能会遇到 CORS(跨源资源共享)错误,您可能需要通过设置Access-Control-Allow-Origin
标头来解决该错误。
尝试登录和注册新用户。
创建仪表板组件
当用户路由到/dashboard
路由时,将显示仪表板组件。它默认显示Header
和CreateInvoice
组件。
Dashboard.vue
在src/components
目录中创建文件。该组件有以下几行代码:
<template>
<div class="container">
<Header v-bind:user="user"/>
<template v-if="this.isactive == 'create'">
<CreateInvoice />
</template>
<template v-else>
<ViewInvoices />
</template>
</div>
</template>
...
在模板下方,添加以下代码行:
...
<script>
import Header from "./Header";
import CreateInvoice from "./CreateInvoice";
import ViewInvoices from "./ViewInvoices";
export default {
name: "Dashboard",
components: {
Header,
CreateInvoice,
ViewInvoices,
},
data() {
return {
isactive: 'create',
title: "Invoicing App",
user : (this.$route.params.user) ? this.$route.params.user : null
};
}
};
</script>
创建 CreateInvoice 组件
该CreateInvoice
组件包含创建新发票所需的表单。在src/components
目录中创建一个新文件:
编辑CreateInvoice
组件看起来像这样:
<template>
<div class="container">
<div class="tab-pane p-3 fade show active">
<div class="row">
<div class="col-md-12">
<h3>Enter details below to create invoice</h3>
<form @submit.prevent="onSubmit">
<div class="form-group mb-3">
<label for="create-invoice-name" class="form-label">Invoice Name:</label>
<input id="create-invoice-name" type="text" required class="form-control" placeholder="Invoice Name" v-model="invoice.name">
</div>
<div class="form-group mb-3">
Invoice Price: <span>${{ invoice.total_price }}</span>
</div>
...
</form>
</div>
</div>
</div>
</div>
</template>
这将创建一个接受发票名称并显示发票总价的表单。总价是通过将发票的各个交易的价格相加而获得的。
我们来看看如何将交易添加到发票中:
...
<form @submit.prevent="onSubmit">
...
<hr />
<h3>Transactions </h3>
<div class="form-group">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#transactionModal">Add Transaction</button>
<!-- Modal -->
<div class="modal fade" id="transactionModal" tabindex="-1" aria-labelledby="transactionModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">Add Transaction</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="form-group mb-3">
<label for="txn_name_modal" class="form-label">Transaction name:</label>
<input id="txn_name_modal" type="text" class="form-control">
</div>
<div class="form-group mb-3">
<label for="txn_price_modal" class="form-label">Price ($):</label>
<input id="txn_price_modal" type="numeric" class="form-control">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Discard Transaction</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" v-on:click="saveTransaction()">Save Transaction</button>
</div>
</div>
</div>
</div>
</div>
...
</form>
...
显示一个按钮供用户添加新交易。单击“添加交易”按钮时,会向用户显示一个模式以输入交易的详细信息。当单击Save Transaction按钮时,一种方法会将其添加到现有事务中。
...
<form @submit.prevent="onSubmit">
...
<div class="col-md-12">
<table class="table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Transaction Name</th>
<th scope="col">Price ($)</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<template v-for="txn in transactions">
<tr :key="txn.id">
<th>{{ txn.id }}</th>
<td>{{ txn.name }}</td>
<td>{{ txn.price }} </td>
<td><button type="button" class="btn btn-danger" v-on:click="deleteTransaction(txn.id)">Delete</button></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="form-group">
<button class="btn btn-primary">Create Invoice</button>
{{ loading }}
{{ status }}
</div>
</form>
...
现有交易以表格格式显示。当删除按钮被点击,有问题的交易从交易列表中删除和Invoice Price
重新计算。最后,Create Invoice
按钮触发一个函数,然后准备数据并将其发送到后端服务器以创建发票。
我们也来看看组件的组件结构Create Invoice
:
...
<script>
import axios from "axios";
export default {
name: "CreateInvoice",
data() {
return {
invoice: {
name: "",
total_price: 0
},
transactions: [],
nextTxnId: 1,
loading: "",
status: ""
};
},
methods: {
...
}
};
</script>
首先,您定义了组件的数据属性。该组件将有一个发票对象,其中包含发票name
和total_price
。它还有一个transactions
带有nextTxnId
索引的数组。这将跟踪事务和变量以向用户发送状态更新。
...
methods: {
saveTransaction() {
// append data to the arrays
let name = document.getElementById("txn_name_modal").value;
let price = document.getElementById("txn_price_modal").value;
if (name.length != 0 && price > 0) {
this.transactions.push({
id: this.nextTxnId,
name: name,
price: price
});
this.nextTxnId++;
this.calcTotal();
// clear their values
document.getElementById("txn_name_modal").value = "";
document.getElementById("txn_price_modal").value = "";
}
},
...
}
...
CreateInvoice
组件的方法也在此处定义。该saveTransaction()
方法获取交易表单模式中的值,然后将它们添加到交易列表中。该deleteTransaction()
方法从交易列表中删除现有交易对象,而该calcTotal()
方法在添加或删除新交易时重新计算总发票价格。
...
methods: {
...
deleteTransaction(id) {
let newList = this.transactions.filter(function(el) {
return el.id !== id;
});
this.nextTxnId--;
this.transactions = newList;
this.calcTotal();
},
calcTotal() {
let total = 0;
this.transactions.forEach(element => {
total += parseInt(element.price, 10);
});
this.invoice.total_price = total;
},
...
}
...
最后,该onSubmit()
方法会将表单提交给后端服务器。在方法中,formData
和axios
用于发送请求。包含交易对象的交易数组被分成两个不同的数组。一个数组保存交易名称,另一个保存交易价格。然后服务器尝试处理请求并将响应发送回用户。
...
methods: {
...
onSubmit() {
const formData = new FormData();
this.transactions.forEach(element => {
formData.append("txn_names[]", element.name);
formData.append("txn_prices[]", element.price)
});
formData.append("name", this.invoice.name);
formData.append("user_id", this.$route.params.user.id);
this.loading = "Creating Invoice, please wait ...";
// Post to server
axios.post("http://localhost:3128/invoice", formData).then(res => {
// Post a status message
this.loading = "";
if (res.data.status == true) {
this.status = res.data.message;
} else {
this.status = res.data.message;
}
});
}
}
...
当您返回应用程序localhost:8080
并登录时,您将被重定向到仪表板。
创建 ViewInvoice 组件
现在您可以创建发票,下一步是创建发票及其状态的可视化图片。为此,请在应用程序目录中创建一个ViewInvoices.vue
文件src/components
。
将文件编辑为如下所示:
<template>
<div>
<div class="tab-pane p-3 fade show active">
<div class="row">
<div class="col-md-12">
<h3>Here is a list of your invoices</h3>
<table class="table">
<thead>
<tr>
<th scope="col">Invoice #</th>
<th scope="col">Invoice Name</th>
<th scope="col">Status</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<template v-for="invoice in invoices">
<tr :key="invoice.id">
<th scope="row">{{ invoice.id }}</th>
<td>{{ invoice.name }}</td>
<td v-if="invoice.paid == 0">Unpaid</td>
<td v-else>Paid</td>
<td><a href="#" class="btn btn-success">To Invoice</a></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
...
上面的模板包含一个表格,显示用户创建的发票。它还有一个按钮,当单击发票时,会将用户带到单个发票页面。
...
<script>
import axios from "axios";
export default {
name: "ViewInvoices",
data() {
return {
invoices: [],
user: this.$route.params.user
};
},
mounted() {
axios
.get(`http://localhost:3128/invoice/user/${this.user.id}`)
.then(res => {
if (res.data.status == true) {
this.invoices = res.data.invoices;
}
});
}
};
</script>
该ViewInvoices
组件具有作为发票数组和用户详细信息的数据属性。从路由参数中获取用户详细信息。当组件为 时mounted
,GET
会向后端服务器发出请求以获取用户创建的发票列表,然后使用之前显示的模板显示这些发票列表。
当您转到 时/dashboard
,单击 上的查看发票选项Navigation
以查看发票和付款状态列表。
结论
在本系列的这一部分中,您使用 Vue 的概念配置了发票应用程序的用户界面。