如何使用Node构建轻量级发票应用程序:用户界面

来自菜鸟教程
跳转至:导航、​搜索

介绍

在上一教程中,您为发票应用程序构建了后端服务器。 在本教程中,您将构建用户将与之交互的应用程序部分,称为用户界面。

注意: 这是一个 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目录旁边。 这引入了分离 serverclient 的常见做法。


在您的终端窗口中,使用以下命令:

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.vuesrc/views/About.vuesrc/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 组件有一个名为 userprop。 这个 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 属性,让用户知道他们的表单何时被处理。 最后,使用 axiosPOST 请求发送到后端服务器。 当从服务器接收到状态为 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方法。

通常,当点击表单的提交按钮时,会通过 GETPOST 请求提交表单。 我们没有使用它,而是在创建表单时添加了 <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 路由时,将显示仪表板组件。 默认显示 HeaderCreateInvoice 组件。

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>

首先,您定义了组件的数据属性。 该组件将有一个包含发票 nametotal_price 的发票对象。 它还有一个 transactionsnextTxnId 索引的数组。 这将跟踪事务和变量以向用户发送状态更新。

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() 方法将表单提交给后端服务器。 在该方法中,formDataaxios用于发送请求。 包含交易对象的交易数组被分成两个不同的数组。 一个数组保存交易名称,另一个保存交易价格。 然后,服务器尝试处理请求并将响应发送回用户。

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 的概念配置了发票应用程序的用户界面。

继续学习 如何使用 Vue 和 Node 构建轻量级发票应用程序:JWT 身份验证和发送发票