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

介绍

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

注意:这是 3 部分系列的第 2 部分。第一个教程是How To Build a Lightweight Invoicing App with Node: Database and API第三个教程是如何使用 Vue 和 Node 构建轻量级发票应用:JWT 身份验证和发送发票

本教程中的用户界面将使用Vue构建,并允许用户登录以查看和创建发票。

先决条件

要完成本教程,您需要:

本教程已通过 Node v16.1.0、npmv7.12.1、Vue v2.6.11、Vue Router v3.2.0、axiosv0.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文档的 :

公共/index.html
<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文档的 :

公共/index.html
<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使用以下代码行替换 的内容

源代码/App.vue
<template>
  <div id="app">
    <router-view/>
  </div>
</template>

你可以忽略或删除src/views/Home.vuesrc/views/About.vue以及src/components/HelloWorld.vue自动生成的文件。

此时,您有了一个带有 Axios 和 Bootstrap 的新 Vue 项目。

第 2 步 – 配置 Vue Router

对于此应用程序,您将有两条主要路线:

  • / 呈现登录页面
  • /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

这指定了当用户访问您的应用程序时应该向用户显示的组件。

第 3 步 – 创建组件

组件使您的应用程序的前端更加模块化和可重用。此应用程序将具有以下组件:

  • 标题
  • 导航
  • 注册(并登录)
  • 仪表盘
  • 创建发票
  • 查看发票

创建标题组件

Header组件显示应用程序的名称以及Navigation用户是否已登录。

Header.vuesrc/components目录中创建一个文件组件文件有以下几行代码:

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 组件有一个prop名为user. prop将由将使用标头组件的任何组件传递。在标题的模板中,Navigation组件被导入并使用条件渲染来确定是否Navigation应该显示。

创建导航组件

Navigation组件是包含不同操作链接的侧边栏。

Navigation.vue/src/components目录中创建一个新组件该组件具有以下模板:

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组件属性,让用户知道他们的表单何时被处理。最后,使用 将POST请求发送到后端服务器axios当从服务器收到状态为 的响应时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="[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组件方法被调用。

通常,当表单的提交按钮被点击时,表单是通过一个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="[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路由时,将显示仪表板组件默认显示HeaderCreateInvoice组件。

Dashboard.vuesrc/components目录中创建文件该组件有以下几行代码:

源代码/组件/仪表板.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>

...

在模板下方,添加以下代码行:

源代码/组件/仪表板.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>

        ...

显示一个按钮供用户添加新交易。单击添加交易”按钮时,会向用户显示一个模式以输入交易的详细信息。单击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>

        ...

现有交易以表格格式显示。删除按钮被点击,有问题的交易从交易列表中删除和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它还有一个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()方法会将表单提交给后端服务器。在方法中,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 组件

现在您可以创建发票,下一步是创建发票及其状态的可视化图片。为此,请在应用程序目录中创建一个ViewInvoices.vue文件src/components

将文件编辑为如下所示:

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组件具有作为发票数组和用户详细信息的数据属性。从路由参数中获取用户详细信息。当组件为 时mountedGET会向后端服务器发出请求以获取用户创建的发票列表,然后使用之前显示的模板显示这些发票列表。

当您转到 时/dashboard,单击 上的查看发票选项Navigation以查看发票和付款状态列表。

结论

在本系列的这一部分中,您使用 Vue 的概念配置了发票应用程序的用户界面。

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

觉得文章有用?

点个广告表达一下你的爱意吧 !😁