介绍
在上一教程中,您为发票应用程序构建了后端服务器。 在本教程中,您将构建用户将与之交互的应用程序部分,称为用户界面。
注意: 这是一个 3 部分系列的第 2 部分。 第一个教程是如何使用Node构建一个轻量级的发票应用程序:数据库和API。 第三篇教程是【X22X】如何用Vue和Node搭建轻量级的发票应用:JWT认证和发送发票【X125X】。
本教程中的用户界面将使用 Vue 构建,并允许用户登录查看和创建发票。
先决条件
要完成本教程,您需要:
- Node.js 安装在本地,您可以按照【X57X】如何安装Node.js 并创建本地开发环境【X126X】进行。
本教程已使用 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
如果您在 Web 浏览器中访问本地应用程序(通常位于 localhost:8080
),您将看到 "Welcome to Your Vue.js App"
消息。
这将创建一个示例 Vue
项目,我们将在本文中构建它。
对于此发票应用程序的前端,将向后端服务器发出大量请求。
为此,我们将使用 axios。 要安装 axios
,请在项目目录中运行命令:
npm install axios@0.21.1
要在应用程序中允许一些默认样式,您将使用 Bootstrap
。
首先,在代码编辑器中打开 public/index.html
文件。
将 Bootstrap 的 CDN 托管 CSS 文件添加到文档的 head
中:
公共/index.html
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x" crossorigin="anonymous">
将 Popper 和 Bootstrap 的 CDN 托管 JavaScript 文件添加到文档的 head
中:
公共/index.html
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js" integrity="sha384-IQsoLXl5PILFhosVNubq5LC7Qb9DXgDA9i+tQ8Zj3iwWAwPtgFTxbJ8NT4GN1R8p" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/js/bootstrap.min.js" integrity="sha384-Atwg2Pkwv9vp0ygtn1JAojH0nYbwNJLPhwyoVbhoPwBhjQPR5VtM2+xf0Uwh9KtT" crossorigin="anonymous"></script>
您可以将 App.vue
的内容替换为以下代码行:
src/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 路由器
对于此应用程序,您将有两条主要路线:
/
渲染登录页面/dashboard
渲染用户仪表板
要配置这些路由,请打开 src/router/index.js
并使用以下代码行更新它:
src/路由器/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
这指定了在用户访问您的应用程序时应该向用户显示的组件。
第三步——创建组件
组件允许您的应用程序的前端更加模块化和可重用。 此应用程序将具有以下组件:
- 标题
- 导航
- 注册(并登录)
- 仪表盘
- 创建发票
- 查看发票
创建标题组件
Header
组件显示应用程序的名称,如果用户已登录,则显示 Navigation
。
在src/components
目录下创建一个Header.vue
文件。 组件文件有以下几行代码:
src/components/Header.vue
<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 组件有一个名为 user
的 prop
。 这个 prop
将由任何将使用标头组件的组件传递。 在标题的模板中,Navigation
组件被导入并使用条件渲染来确定是否应该显示Navigation
。
创建导航组件
Navigation
组件是侧边栏,将容纳不同动作的链接。
在/src/components
目录下新建Navigation.vue
组件。 该组件具有以下模板:
src/components/Navigation.vue
<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
文件并添加以下代码行:
src/components/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
目录下新建一个文件。
首先,创建组件:
src/components/SignUp.vue
<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
组件并指定组件的数据属性。
接下来,创建处理提交数据时发生的情况的方法:
src/components/SignUp.vue
... methods: { validate() { // checks to ensure passwords match if (this.model.password != this.model.c_password) { return false; } return true; }, ... } ...
validate()
方法执行检查以确保用户发送的数据符合我们的要求。
src/components/SignUp.vue
... 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
属性,让用户知道他们的表单何时被处理。 最后,使用 axios
将 POST
请求发送到后端服务器。 当从服务器接收到状态为 true
的响应时,用户将被定向到仪表板。 否则,将向用户显示错误消息。
src/components/SignUp.vue
... 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
方法。 准备好数据并将其发送到后端服务器以对用户进行身份验证。 如果用户存在并且详细信息匹配,则将用户定向到他们的仪表板。
现在,看一下注册模板:
src/components/SignUp.vue
<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="example@example.com" 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">
以覆盖默认行为并指定应调用登录函数。
注册表也是这样的:
src/components/SignUp.vue
<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="example@example.com" 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
组件。
在src/components
目录下创建Dashboard.vue
文件。 该组件具有以下代码行:
src/component/Dashboard.vue
<template> <div class="container"> <Header v-bind:user="user"/> <template v-if="this.isactive == 'create'"> <CreateInvoice /> </template> <template v-else> <ViewInvoices /> </template> </div> </template> ...
在模板下方,添加以下代码行:
src/component/Dashboard.vue
... <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
组件,如下所示:
src/components/CreateInvoice.vue
<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>
这将创建一个接受发票名称并显示发票总价的表单。 总价是通过将发票的各个交易的价格相加得出的。
让我们看看如何将交易添加到发票中:
src/components/CreateInvoice.vue
... <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> ...
显示一个按钮供用户添加新交易。 单击 Add Transaction 按钮时,将向用户显示一个模式以输入交易的详细信息。 单击 Save Transaction 按钮时,一个方法会将其添加到现有事务中。
src/components/CreateInvoice.vue
... <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> ...
现有交易以表格格式显示。 当点击Delete按钮时,有问题的交易从交易列表中被删除并且Invoice Price
被重新计算。 最后,Create Invoice
按钮触发一个函数,然后准备数据并将其发送到后端服务器以创建发票。
我们也来看看Create Invoice
组件的组件结构:
src/components/CreateInvoice.vue
... <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
索引的数组。 这将跟踪事务和变量以向用户发送状态更新。
src/components/CreateInvoice.vue
... 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()
方法在添加或删除新交易时重新计算总发票价格。
src/components/CreateInvoice.vue
... 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
用于发送请求。 包含交易对象的交易数组被分成两个不同的数组。 一个数组保存交易名称,另一个保存交易价格。 然后,服务器尝试处理请求并将响应发送回用户。
src/components/CreateInvoice.vue
... 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 组件
现在您可以创建发票,下一步是创建发票及其状态的可视化图片。 为此,请在应用程序的 src/components
目录中创建一个 ViewInvoices.vue
文件。
将文件编辑为如下所示:
src/components/ViewInvoices.vue
<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> ...
上面的模板包含一个表格,显示用户创建的发票。 它还有一个按钮,当单击发票时,该按钮将用户带到单个发票页面。
src/components/ViewInvoice.vue
... <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 的概念配置了发票应用程序的用户界面。