介绍
在上一教程中,您为发票应用程序构建了后端服务器。 在本教程中,您将构建用户将与之交互的应用程序部分,称为用户界面。
注意: 这是一个 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 的概念配置了发票应用程序的用户界面。