Vue3-示例-二-

63 阅读1小时+

Vue3 示例(二)

原文:zh.annas-archive.org/md5/84EBE0BE98F4DE483EBA9EF82A25ED12

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:使用 Ionic 构建多功能计算器移动应用

在前四章中,我们使用 Vue 3 构建了各种类型的 Web 应用程序。我们也可以使用 Vue 3 创建移动应用程序,但不能仅使用 Vue 3 创建它们。我们可以使用将 Vue 3 作为基础框架的移动应用程序框架来创建移动应用程序。在第四章《构建照片管理桌面应用程序》中,我们使用 Vue Router 构建了一个 Web 应用程序,以便我们的应用程序可以拥有多个页面。Vue Router 让我们能够创建稍微复杂的应用程序。

在本章中,我们将进一步从构建 Web 应用程序的知识中迈出,以便开始构建移动应用程序。我们将构建一个计算器应用程序,让我们能够转换货币和计算小费。它还会记住我们所做的计算,因此我们可以轻松地返回重新进行。

此外,我们还将涵盖以下主题:

  • 介绍 Ionic Vue

  • 创建我们的 Ionic Vue 移动应用项目

  • 安装项目的包

  • 将计算器添加到我们的计算器应用程序中

技术要求

本章项目的源代码可以在github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter05找到。

介绍 Ionic Vue

Ionic Vue是一个移动应用程序框架,让我们能够使用 TypeScript 和 Vue 构建应用程序。它还有基于 React 和 Angular 的版本。它配备了许多组件,我们可以像其他 UI 框架一样将其添加到我们的应用程序中。它们包括常见的输入、菜单、图标、列表等等。编译后的 Ionic 应用程序在 Web 视图中运行,因此我们可以在我们的应用程序中使用本地存储、地理位置和其他浏览器 API 等 Web 技术。

它还配备了内置工具,让我们能够自动构建移动应用程序,无需从头开始设置所有内容。Ionic Vue 默认创建使用组合 API 的组件,因此我们将使用它来构建更模块化并且与 TypeScript 更好地配合的 Vue 应用程序。

理解组合 API

Composition API 更适合与 TypeScript 一起使用,因为它不引用具有动态结构的this关键字。相反,Composition API 的所有部分,包括其库,都与具有清晰参数和返回类型的函数兼容。这使我们可以轻松为它们定义 TypeScript 类型定义。Vue Router 4 和 Vuex 4 与 Vue 3 的 Composition API 兼容,因此我们可以在 Ionic Vue 应用程序中一起使用它们。

使用 Composition API,我们仍然有组件对象,但其结构与选项 API 中的完全不同。选项和 Composition API 之间唯一相同的属性是components属性。它们都允许我们在两个 API 中注册组件。我们的组件逻辑主要在setup()方法中。这是我们定义响应式属性、计算属性、观察者和方法的地方。第三方库还可以为我们提供钩子,这些钩子是我们可以在 setup 函数中调用的函数,以便从库中获得我们想要的功能。例如,Vuex 4 为我们提供了useStore钩子,以便我们可以访问存储。Vue Router 4 带有useRouter钩子,让我们在应用程序中导航。

我们定义响应式和计算属性的方式与选项 API 不同。在选项 API 中,我们在data()方法中定义和初始化我们的响应式属性。在 Composition API 中,我们调用ref函数来定义持有原始值的响应式属性;然后我们调用reactive来定义具有对象值的响应式属性。要在 Composition API 中定义计算属性,我们调用带有回调的computed函数,该回调引用其他响应式属性以创建计算属性。

观察者是使用watch函数创建的。它接受一个回调来返回我们想要观察值的响应式属性。我们传递给watch函数的第二个参数是一个回调,让我们在观察的值发生变化时执行某些操作。我们可以使用回调的第一个参数获取被观察的响应式属性的最新值。第三个参数包含观察者的选项。我们可以在其中设置深度和立即属性,就像在选项 API 中观察者一样。

方法也被添加到setup函数中。我们可以使用箭头函数或常规函数来定义它们,因为 this 的值并不重要。响应式属性和方法必须在我们在setup()方法中返回的对象中返回,以便在模板中使用它们。这样,它们可以在我们的代码中使用。

理解 TypeScript

TypeScript是微软开发的一种语言,它是 JavaScript 的扩展。它为我们的代码提供了编译时的数据类型检查。然而,它并没有为我们提供额外的运行时数据类型检查,因为 TypeScript 在运行之前会编译成 JavaScript。在组合 API 中,我们的组件不引用this关键字,因此我们不必担心它具有错误的值。

使用 TypeScript 的好处是确保原始值、对象和变量的类型安全,这在 JavaScript 中是不存在的。在 JavaScript 中,我们可以将任何东西赋给任何变量。这当然会成为一个问题,因为我们可能会将东西赋给我们通常不会使用的数据类型。此外,函数可以接受任何参数,我们可以以任何顺序传递任何参数给函数,因此如果我们传递函数不期望的参数,可能会遇到问题。此外,任何地方都可能变成nullundefined,因此我们必须确保只有我们期望为 null 或 undefined 的地方才有这些值。JavaScript 函数也可以返回任何东西,因此 TypeScript 也可以限制这一点。

TypeScript 的另一个重要特性是我们可以创建接口来限制对象的结构。我们可以指定对象属性及其类型,以便限制对象具有给定的属性,并且属性具有我们指定的数据类型。这可以防止我们将对象分配给我们不期望的变量和参数,并且还为我们提供了在支持 TypeScript 的文本编辑器中无法获得的自动完成功能。这是因为对象的结构是固定的。接口可以具有可选和动态属性,以便让我们保持 JavaScript 对象的灵活性。

为了保持 JavaScript 的灵活性,TypeScript 带有联合和交集类型。联合类型是指我们使用逻辑 OR 运算符将多个类型组合在一起的情况。具有联合类型的变量意味着变量可以是联合类型列表中的一个类型。交集类型是使用逻辑 AND 运算符将多个类型组合在一起的情况。将类型设置为交集类型的变量必须具有交集中所有类型的成员。

为了保持类型规范的简洁,TypeScript 带有type关键字,它允许我们创建类型别名。类型别名可以像常规类型一样使用,因此我们可以将类型别名分配给变量、属性、参数、返回类型等等。

在我们的移动应用中,我们将为小费计算器、货币转换器和一个带有过去计算列表的主页添加页面。我们将我们所做的任何计算都保存在本地存储中,以便以后可以返回到它们。历史记录通过vuex-persistedstate插件保存到本地存储中。该插件与 Vuex 4 兼容,它允许我们直接将 Vuex 状态保存到本地存储中,而无需编写任何额外的代码来实现这一点。

现在我们已经了解了 Vue 的组合 API、TypeScript 和 Ionic 的基础知识,我们可以开始用它构建我们的应用程序。

创建我们的 Ionic Vue 移动应用项目

我们可以通过安装 Ionic CLI 来创建我们的 Ionic Vue 项目。首先,我们必须通过运行以下命令来安装 Ionic CLI:

npm install -g @ionic/cli

然后,我们必须通过转到我们想要运行项目文件夹的文件夹来创建我们的 Ionic Vue 项目。我们可以使用以下命令来做到这一点:

ionic start calculator-app sidemenu --type vue

sidemenu选项允许我们创建一个带有侧边菜单的 Ionic 项目。这将在创建菜单和页面时节省我们的时间。--type vue选项允许我们创建一个 Ionic Vue 项目。

我们可以使用以下命令获取所有选项的帮助,并查看每个选项的解释:

  • ionic –help

  • ionic <command> --help

  • ionic <command><subcommand> --help

我们应该在我们的项目目录中运行ionic <command> --help

使用 Capacitor 和 Genymotion

Ionic Vue 项目使用 Capacitor 进行服务和构建。Capacitor 将在 Android Studio 中打开项目;然后,我们可以从那里启动并在模拟器或设备中预览我们的应用程序。对于这个项目,我们将使用 Genymotion 模拟器预览我们的应用程序。它速度快,并且有一个插件,让我们可以从 Android Studio 中启动。我们可以从 www.genymotion.com/download/ 下载 Genymotion 模拟器,而 Android Studio 可以从 developer.android.com/studio 下载。

安装了 Genymotion 后,我们必须从 Genymotion UI 中创建一个虚拟机。为此,我们可以点击 加号 按钮,然后添加我们想要的设备。我们应该添加一个具有较新版本的 Android(如 Android 7 或更高版本)的设备。其他选项可以根据我们的喜好选择。要安装 Android Studio 的 Genymotion 插件,请按照 www.genymotion.com/plugins/ 上的说明进行操作。这将让我们在 Genymotion 中运行我们的 Android Studio 项目。

接下来,在我们项目的 package.json 文件中,如果在脚本部分看不到 ionic:serveionic:build 脚本,我们可以通过在 package.json 文件的脚本部分中写入以下代码来添加它们:

"ionic:serve": "vue-cli-service serve",
"ionic:build": "vue-cli-service build"

然后,我们可以运行 ionic build 来构建我们的代码,以便稍后使用 Capacitor 进行服务。

完成后,我们可以运行以下命令为 Android 项目添加依赖项:

npx cap add android
npx cap sync

这也是必需的,这样我们才能将我们的项目作为 Android 应用程序运行。

完成这些命令后,我们可以运行以下命令,以便我们可以使用实时重新加载运行我们的应用程序,并从 Genymotion 中进行网络访问。

ionic capacitor run android --livereload --external --address=0.0.0.0

这样,我们就可以像任何其他应用程序一样访问互联网。它还运行 ionic:serve 脚本,这样我们就可以在浏览器中预览我们的应用程序。在浏览器中预览我们的应用程序比在模拟器中更快,所以我们可能会想要这样做:

图 5.1 – Genymotion 模拟器

图 5.1 – Genymotion 模拟器

如果我们想在 Genymotion 中预览,我们可以转到 Android Studio,一旦我们运行 ionic capacitor run 命令,它应该会自动打开。然后,我们可以按 Alt+Shift+F10 打开运行应用程序对话框,然后选择要运行的应用程序。

现在我们已经设置好了 Vue Ionic 项目,我们必须安装一些额外的软件包,以便创建我们的移动应用程序。

为我们的项目安装包

我们必须安装一些项目中需要的依赖项,但它们尚未安装。我们可以使用 Axios 来进行 HTTP 请求以获取汇率。uuid模块让我们可以为我们的历史记录生成唯一的 ID。Vuex 不随 Ionic 项目一起提供,因此我们必须安装它。我们还必须安装vuex-persistedstate模块,以便我们可以将 Vuex 状态数据保存到本地存储中。

要安装这些包,请运行以下命令:

npm install axios uuid vuex@next vuex-persistedstate

下一个版本的 Vuex 是 4.x 版本,与 Vue 3 兼容。

将计算器添加到我们的计算器应用程序中

现在我们的项目准备好了,我们可以开始在我们的应用程序上工作。我们首先添加路由定义,将 URL 路径映射到我们将创建的页面组件。然后我们将为每个功能添加组件。然后我们将添加 Vuex 存储,其中包含将存储数据持久化到本地存储的代码,这样我们可以随时使用数据。

添加路由

首先,我们将致力于为我们的计算器应用程序添加路由。在src/router/index.ts文件中,编写以下代码:

import { createRouter, createWebHistory } from '@ionic/vue-
 router';
import { RouteRecordRaw } from 'vue-router';
const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    component: () => import('../views/Home.vue')
  },
  {
    path: '/currency-converter',
    component: () => 
      import('../views/CurrencyConverter.vue')
  },
  {
    path: '/tips-calculator',
    component: () => import('../views/TipsCalculator.vue')
  }
]
const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})
export default router

在这个文件中,我们有routes数组,我们可以用它来添加我们将要添加到计算器应用程序中的页面的路由。routes数组的类型是Array<RouteRecordRaw>。这意味着routes数组中的对象必须具有路径和组件属性。path属性必须是一个字符串,而组件可以是一个组件或返回一个解析为组件的 promise 的函数。如果对象不符合Array<RouteRecordRaw>指定的结构,TypeScript 编译器在构建代码时会给我们一个错误。

每当我们更改任何代码文件时,代码都会被构建,因为我们设置了livereload选项,所以我们几乎立即会得到编译器错误。这可以防止大多数与数据类型相关的错误在运行时发生。类型定义内置在vue-router模块中,因此我们不必担心缺少数据类型。

添加货币转换器页面

接下来,我们将添加货币转换器页面。要添加它,首先创建src/views/CurrencyConverter.vue文件。然后,我们必须通过编写以下代码将标题添加到模板中:

<template>
  <ion-page>
    <ion-header translucent>
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-menu-button></ion-menu-button>
        </ion-buttons>
        <ion-title>Currency Converter</ion-title>
      </ion-toolbar>
    </ion-header>
    ...
  </ion-page>
</template>

ion-page组件是页面容器,让我们可以在其中添加内容。ion-toolbar组件在页面顶部添加了一个工具栏。ion-buttons组件是按钮的容器,在其中,我们必须在开始插槽中添加ion-menu-button,这样我们就可以在屏幕左上角添加一个菜单按钮。当我们点击它时,ion-menu-button组件将打开左侧菜单。ion-title组件包含页面标题。它位于左上角。

接下来,我们必须添加ion-content组件,以在货币转换页面上添加内容。例如,我们可以编写以下代码:

<template>
  <ion-page>
    ...
    <ion-content fullscreen>
      <div id="container">
        <ion-list>
          <ion-item>
            <ion-label :color="!amountValid ? 'danger' : 
              undefined">
              Amount to Convert
            </ion-label>
            <ion-input
              class="ion-text-right"
              type="number"
              v-model="amount"
              required
              placeholder="Amount"
            ></ion-input>
          </ion-item>
          ...
        </ion-list>
        ...
      </div>
    </ion-content>
  </ion-page>
</template>

在这里,我们添加了ion-list组件,以便在我们的页面上添加一个列表。它让我们可以向我们的应用程序添加一个项目列表。在ion-list中,我们添加ion-item以添加列表项组件。ion-label让我们将标签添加到列表项中。标签文本的color属性由color属性设置。amountValid属性是一个计算属性,用于检查amount响应式属性是否有效。ion-input组件将输入呈现到我们的应用程序中。我们将type设置为number,以使输入成为数字输入。

placeholder属性让我们可以向我们的应用程序添加占位符。ion-text-right类让我们把输入放在右侧。这是 Ionic 框架提供的一个类。v-model指令让我们将amount响应式属性绑定到输入值,这样我们就可以在组件代码中使用输入的值。

ion-contentfullscreen属性使页面全屏。

接下来,我们将向ion-list组件添加更多项目:

<template>
  <ion-page>
...
    ...
    <ion-content fullscreen>
      <div id="container">
        <ion-list>
          ...
          <ion-item>
            <ion-label> Currency to Convert From
              </ion-label>
            <ion-select
              v-model="fromCurrency"
              ok-text="Okay"
              cancel-text="Dismiss"
            >
              <ion-select-option
                :value="c.abbreviation"
                v-for="c of fromCurrencies"
                :key="c.name"
              >
                {{ c.name }}
              </ion-select-option>
            </ion-select>
          </ion-item>
          ...
        </ion-list>
        ...
      </div>
    </ion-content>
  </ion-page>
</template>

在这里,我们向我们的ion-list添加了更多ion-itemsion-select组件让我们可以从下拉菜单中添加要转换的货币,这样我们就可以选择金额所在的货币。我们将fromCurrency绑定到我们在下拉菜单中选择的值,以在组件代码中获取所选项目。ok-text属性设置下拉菜单中的确定文本,而cancel-text属性包含取消按钮的文本。ion-select组件让我们显示一个带有单选按钮的对话框,让我们显示可供我们选择的项目。然后,当我们点击或轻触确定按钮时,我们可以选择该项目。

ion-select-option组件让我们向选择对话框框添加选项。我们使用v-for指令循环遍历fromCurrencies响应式属性,这是我们从要转换的货币对话框中筛选出的selected选项创建的计算属性,稍后我们将添加。这样,我们就不能在两个下拉菜单中选择相同的货币,因此货币转换是有意义的。

接下来,我们将添加另一个选择对话框,让我们选择要将金额转换为的货币。为此,我们可以编写以下代码:

<template>
  <ion-page>
    ...
    <ion-content fullscreen>
      <div id="container">
        <ion-list>
          ...
...
                {{ c.name }}
              </ion-select-option>
            </ion-select>
          </ion-item>
          <ion-item>
            <ion-button size="default" 
              @click.stop="calculate">
              Calculate
            </ion-button>
          </ion-item>
        </ion-list>
        ...
      </div>
    </ion-content>
  </ion-page>
</template>

toCurrencies响应式属性是一个计算属性,其中包含一个条目,其值为从fromCurrency中筛选出的值。这意味着我们不能在两个下拉菜单中选择相同的货币。

我们还添加了Calculate按钮,这样我们就可以计算转换后的金额。我们将很快添加calculate()方法。

接下来,我们将添加另一个ion-list。这将添加一个列表,用于显示转换后的金额的标签。为此,我们可以编写以下代码:

<template>
  <ion-page>
    <ion-header translucent>
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-menu-button></ion-menu-button>
        </ion-buttons>
        <ion-title>Currency Converter</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content fullscreen>
      <div id="container">
        ...
        <ion-list v-if="result">
          <ion-item>
            <ion-label>Result</ion-label>
            <ion-label class="ion-text-right">
              {{ amount }} {{ fromCurrency }} is {{ 
                result.toFixed(2) }}
              {{ toCurrency }}
            </ion-label>
          </ion-item>
        </ion-list>
      </div>
    </ion-content>
  </ion-page>
</template>

在这里,我们显示了输入的金额和fromCurrency。我们还显示了结果和我们选择的toCurrency选项。我们调用了带有参数2toFixed来将结果四舍五入到两位小数。

添加脚本标签

接下来,我们将添加一个带有lang属性设置为tsscript标签,以便我们可以添加 TypeScript 代码。首先,我们将添加import语句,以便添加我们在代码中将使用的组件和其他项目:

<script lang="ts">
import {
  IonButtons,
  IonContent,
  IonHeader,
  ...
} from "@ionic/vue";
import { computed, reactive, ref, watch } from "vue";
import { currencies as currenciesArray } from "../constants";
import axios from "axios";
import { useStore } from "vuex";
import { CurrencyConversion } from "@/interfaces";
import { v4 as uuidv4 } from "uuid";
import { useRoute } from "vue-router";
...
</script>

请参阅本书的 GitHub 存储库,了解可以注册的完整组件列表。

computed函数让我们可以创建与 Composition API 一起使用的计算属性。reactive函数让我们可以创建具有对象值的响应式属性。ref属性让我们可以创建具有原始值的计算属性。watch函数让我们可以创建与 Composition API 一起使用的观察者。

currenciesArray变量是一个货币数组,我们将使用它来创建fromCurrenciestoCurrencies计算属性。axios对象让我们可以使用 Axios HTTP 客户端进行 HTTP 请求。useStore变量是一个函数,让我们可以访问我们的 Vuex 存储。CurrencyConversion接口提供了我们用来限制添加到历史记录列表的对象结构的接口。uuidv4变量是一个函数,让我们可以创建 UUID,这是我们分配给历史记录条目以标识它们的唯一 ID。useRoute函数让我们可以访问路由对象,以获取当前 URL 路径和 URL 的其他部分。

接下来,我们将通过将导入的组件添加到components属性中来注册组件。为此,我们可以编写以下代码:

<script lang="ts">
...
export default {
  name: "CurrencyConverter",
  components: {
    IonButtons,
    ...
  },
  ...
};
</script>

请查看此书的 GitHub 存储库,以获取可以注册的所有组件的完整列表。我们只需将所有import组件放入component属性中进行注册。

setup方法上工作

接下来,我们将开始在setup()方法上工作,并向其中添加响应式和计算属性。我们还将添加 watchers,它们让我们可以观察路由的变化。首先,我们将编写以下代码:

<script lang="ts">
...
export default {
  ...
  setup() {
    const amount = ref(0);
    const fromCurrency = ref("USD");
    const toCurrency = ref("CAD");
    const result = ref(0);
    const currencies = reactive(currenciesArray);
    const store = useStore();
    const route = useRoute();
    ...
    return {
      amount,
      fromCurrency,
      toCurrency,
      currencies,
      fromCurrencies,
      toCurrencies,
      amountValid,
      calculate,
      result,
      addToHistory,
    };
  },
};
</script>

我们调用useStore()方法返回存储对象,其中包含 Vuex 存储。我们需要 Vuex 存储来提交 mutations,这让我们可以向历史记录中添加条目。因为我们将向 Vuex 存储添加vuex-persistsedstate插件,历史记录条目将自动添加到本地存储中。同样,我们调用useRoute函数返回路由对象,让我们可以访问路由对象。我们需要路由对象来让我们观察id query参数的查询字符串。如果我们通过 ID 找到项目,那么我们可以使用它们从本地存储中获取的值,从而设置fromCurrencytoCurrencyamount的值。

此外,我们调用ref函数来创建amount响应式属性,它们是数字值。fromCurrencytoCurrency响应式属性是字符串值,它们包含我们选择的货币的货币代码。currencies响应式属性是一个响应式数组,其初始值设置为currenciesArray。我们传递给refreactive的参数是每个响应式属性的初始值。

接下来,我们可以通过编写以下代码来添加计算属性:

<script lang="ts">
...
export default {
  ...
  setup() {
    ...
    const fromCurrencies = computed(() => {
      return currencies.filter(
        ({ abbreviation }) => abbreviation !== 
          toCurrency.value
      );
    });
...
    return {
      amount,
      fromCurrency,
      toCurrency,
      currencies,
      fromCurrencies,
      toCurrencies,
      amountValid,
      calculate,
      result,
      addToHistory,
    };  },
};
</script>

我们调用computed函数并使用回调来创建计算属性。与选项 API 一样,我们返回我们想要的计算属性的值。唯一不同的是,我们使用value属性获取基本值响应式属性的值。fromCurrencies响应式属性是通过筛选具有与toCurrency相同值的缩写的货币条目创建的。toCurrencies是通过筛选具有与fromCurrency值相同的缩写值的货币条目创建的。

amountValid计算属性让我们确定在ion-input中输入的金额是否有效。我们希望它是至少为0的数字,因此我们返回该条件,以便我们可以检查这一点。

接下来,我们将通过向setup()方法添加更多项目来向CurrencyConverter组件添加这些方法:

<script lang="ts">
...
export default {
  ...
  setup() {
    ...
    const addToHistory = (entry: CurrencyConversion) =>
      store.commit("addToHistory", entry);
    const calculate = async () => {
      result.value = 0;
      if (!amountValid.value) {
        return;
      ...
      });
      result.value = amount.value * 
        rates[toCurrency.value];
    };    
    ...
    return {
      amount,
      fromCurrency,
      toCurrency,
      currencies,
      fromCurrencies,
      toCurrencies,
      amountValid,
      calculate,
      result,
      addToHistory,
    };  },
};
</script>

addToHistory()方法让我们向 Vuex 存储和本地存储添加历史记录条目,以便我们可以在主页上显示活动。这样,我们以后可以选择它们并进行相同的计算。在签名中,我们使用CurrencyConversion接口注释条目参数的类型,以便我们知道我们向 Vuex 存储和本地存储添加了正确的内容。我们将addToHistory提交到存储,并将历史记录条目作为有效负载。

正在处理计算方法

calculate()方法中,我们将结果的值重置为0。然后,我们调用addToHistory将条目添加到历史记录中。id属性是从uuidv4函数生成的,用于为条目生成唯一 ID。我们从响应式属性值设置其他属性。value属性是访问基本值响应式属性所必需的。

然后,我们使用 Axios 从免费使用的汇率 API 获取汇率。我们只需将基本查询参数设置为我们要转换的货币代码。最后,我们通过将从 API 检索到的汇率乘以金额来计算转换值的结果。

然后,为了完成CurrencyConverter组件,我们添加了查询字符串的监视器。我们监视queryID参数,如果我们从主页打开历史记录条目,它将发生变化。要添加监视器,我们可以编写以下代码:

<script lang="ts">
...
export default {
  ...
  setup() {
    ...
    watch(
      () => route.query,
      (query) => {
        const { id: queryId } = query;
        const { history } = store.state;
        const entry = history.find(
          ({ id }: CurrencyConversion) => id === queryId
        );
        if (!entry) {
          return;
        }
      ...
      fromCurrency,
      toCurrency,
      currencies,
      fromCurrencies,
      toCurrencies,
      amountValid,
      calculate,
      result,
      addToHistory,
    };
  },
};
</script>

要创建观察者,我们传入一个返回route.query的函数来返回查询对象。route变量被分配给我们之前调用的useRoute函数的返回值。然后,我们从函数的第二个参数中获取查询对象的值。我们从查询对象中获取id属性。然后,我们从存储中获取历史状态,其中包含我们在本地存储中存储的所有条目。本地存储会自动由vuex-persistedstate同步到 Vuex 存储中。

我们调用history.find()方法来通过其id查找条目。然后,返回一个条目,并将retrieved属性值设置为响应式属性值。当我们从历史记录中选择条目时,它会自动填充它们。

在第三个参数中,我们有一个对象,其中的 immediate 属性设置为true,以便在组件挂载时立即运行观察者。

我们使用return语句返回要在模板中公开的所有内容。我们包括所有的响应式属性、计算属性和方法,以便它们可以在模板中使用。

项目完成后,货币转换器应如下所示:

图 5.2 - 货币转换器

图 5.2 - 货币转换器

添加小费计算器

接下来,我们将添加TipsCalculator页面组件。要添加它,我们必须添加src/views/TipCalculator.vue文件。在其中,我们将首先添加模板和标题:

<template>
  <ion-page>
    <ion-header translucent>
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-menu-button></ion-menu-button>
        </ion-buttons>
        <ion-title>Tips Calculator</ion-title>
      </ion-toolbar>
    </ion-header>
    ...
  </ion-page>
</template>

ion-headerCurrencyConverter几乎相同,只是ion-title内容不同。

接下来,我们添加ion-content组件以添加页面内容。为此,我们可以编写以下代码:

<template>
  <ion-page>
    ...
    <ion-content fullscreen>
      <div id="container">
        <form>
          <ion-list>
            <ion-item>
              <ion-label :color="!amountValid ? 'danger' : 
                undefined">
                  ...
                  {{ c.name }}
                </ion-select-option>
              </ion-select>
            </ion-item>
      ...            
          </ion-list>
          ...
        </form>
      </div>
    </ion-content>
  </ion-page>
</template>

在上述代码中,我们添加了ion-list和表单控件的 ion 项。我们可以在页面上输入小费前的金额。第二个ion-item组件让我们添加country ion-select控件。它让我们选择一个国家,以便我们可以获得该国家的小费率。小费率是从tippingRate计算属性计算出来的。ion-select-option是从countries响应式数组属性创建的,它提供了我们可以选择的国家列表,以获取它们的小费率。

接下来,我们将添加小费率的显示和计算小费按钮。为此,我们将编写以下代码:

<template>
  <ion-page>
    ...
    <ion-content fullscreen>
      <div id="container">
        <form>
          <ion-list>
            ...
            <ion-item>
              <ion-label>Tipping Rate</ion-label>
              <ion-label class="ion-text-right">{{"> {{ 
                tippingRate }}% </ion-label>
            </ion-item>
            <ion-item>
              <ion-button size="default" 
                @click="calculateTip">
                Calculate Tip
              </ion-button>
            </ion-item>
          </ion-list>
...
          ...
        </form>
      </div>
    </ion-content>
  </ion-page>
</template>

我们只显示tippingRate计算属性的值和计算小费按钮。我们通过添加@click指令并将其设置为calculateTip()方法来添加点击处理程序。

模板的最后部分是计算结果。我们向组件添加ion-list来添加结果。我们显示小费和合计。要添加它,我们可以编写以下代码:

<template>
  <ion-page>
    ...
    <ion-content fullscreen>
      <div id="container">
        <form>
          ...
          <ion-list>
            <ion-item>
              <ion-label>Tip (Local Currency)</ion-label>
              <ion-label class="ion-text-right">{{"> {{ 
                result.tip }} </ion-label>
            </ion-item>
            <ion-item>
              <ion-label>Total (Local Currency)</ion-label>
              <ion-label class="ion-text-right">{{"> {{ 
                result.total }} </ion-label>
            </ion-item>
          </ion-list>
        </form>
      </div>
    </ion-content>
  </ion-page>
</template>

接下来,我们将为TipsCalculator组件添加 TypeScript 代码。它的结构类似于CurrencyConverter组件。首先,我们通过编写以下代码添加导入:

<script lang="ts">
import {
  IonButtons,
  IonContent,
  IonHeader,
  IonMenuButton,
  IonPage,
  IonTitle,
  IonToolbar,
  IonSelect,
  IonSelectOption,
  IonInput,
  IonLabel,
  IonButton,
  IonList,
  IonItem,
} from "@ionic/vue";
import { computed, reactive, ref, watch } from "vue";
import { countries as countriesArray } from "../constants";
import { useStore } from "vuex";
import { TipCalculation } from "@/interfaces";
import { v4 as uuidv4 } from "uuid";
import { useRoute } from "vue-router";
...
</script>

我们像在CurrencyConverter.vue中一样导入所有组件和库。

然后,我们像在CurrencyConverter中一样注册组件:

<script lang="ts">
...
export default {
  name: "TipsCalculator",
  components: {
    IonButtons,
    IonContent,
    IonHeader,
    IonMenuButton,
    IonPage,
    IonTitle,
    IonToolbar,
    IonSelect,
    IonSelectOption,
    IonInput,
    IonLabel,
    IonButton,
    IonList,
    IonItem,
  },
  ...
};
</script>

然后,我们在setup()方法中定义响应式属性并获取存储和路由:

<script lang="ts">
...
export default {
...
  ...
  setup() {
    const store = useStore();
    const route = useRoute();
    const subtotal = ref(0);
    const countries = reactive(countriesArray);
    const country = ref("Afghanistan");
    ...
    return {
      subtotal,
      country,
      countries,
      tippingRate,
      amountValid,
      result,
      calculateTip,
    };
  },
};
</script>

我们像在CurrencyConverter中一样调用useStoreuseRoute。然后,我们使用ref函数创建subtotal响应式属性。由于它的值是一个数字,我们使用ref函数来创建它。country数组的响应式属性是用reactive函数创建的。

接下来,我们必须通过编写以下代码来添加一些计算属性:

<script lang="ts">
...
export default {
  ...
  setup() {
    ...
    const tippingRate = computed(() => {
      if (["United States"].includes(country.value)) {
        return 20;
      } else if (
        ["Canada", "Jordan", "Morocco", "South 
          Africa"].includes(country.value)
      ) {
        return 15;
      } else if (["Germany", "Ireland", 
         "Portugal"].includes(country.value)) {
        return 5;
      }
      return 0;
    });
    const amountValid = computed(() => {
      return +subtotal.value >= 0;
    });
    ...
    return {
      subtotal,
      country,
      countries,
      tippingRate,
      amountValid,
      result,
      calculateTip,
    };
  },
};
</script>

在这里,我们根据所选的国家计算小费率。

amountValid 计算属性让我们检查subtotal值是否有效。我们希望它为0或更大。

接下来,我们将把其余的项目添加到组件中:

<script lang="ts">
...
export default {
  ...
  setup() {
    ...
    const result = reactive({
      tip: 0,
      total: 0,
    });
    const addToHistory = (entry: TipCalculation) =>
      store.commit("addToHistory", entry);
      ...
      tippingRate,
      amountValid,
      result,
      calculateTip,
    };
  },
};
</script>

result 响应式属性包含小费计算的结果。tip 属性包含小费金额。最后,total 属性包含subtotaltip的总和。

calculateTip() 方法让我们计算小费。result 属性的值首先被初始化为0。然后,我们检查amountValid是否为真。如果不是,我们停止运行该函数。否则,我们使用addToHistory函数将历史记录条目添加到存储和本地存储中。接下来,我们在calculateTip()方法的最后两行进行小费计算。

最后,我们通过编写以下代码将观察者添加到setup()方法中:

<script lang="ts">
...
export default {
...
  setup() {
    ...
    watch(
      () => route.query,
      (query) => {
        const { id: queryId } = query;
        const { history } = store.state;
        const entry = history.find(({ id }: TipCalculation)
         => id === queryId);
        if (!entry) {
          return;
        }
        const {
          subtotal: querySubtotal,
          country: queryCountry,
        }: TipCalculation = entry;
        subtotal.value = querySubtotal;
        country.value = queryCountry;
      },
      { immediate: true }
    );
    return {
      subtotal,
      country,
      countries,
      tippingRate,
      amountValid,
      result,
      calculateTip,
    };
  },
};
</script>

就像在CurrencyConverter.vue中一样,我们监视解析的查询字符串对象,并在找到时从历史记录条目中填充值。

最后,我们使用return语句返回所有要暴露给模板的项目,包括任何响应式和计算属性以及方法。完成项目后,我们应该看到以下屏幕:

图 5.3 - 小费计算器

图 5.3 - 小费计算器

添加主页

接下来,我们将添加Home.vue页面组件,这将让我们查看到目前为止我们所做的计算。我们可以通过打开页面重新进行计算,使用历史记录中填充的数值。为了添加计算历史列表,我们将从其模板开始:

<template>
  <ion-page>
    <ion-header translucent>
      <ion-toolbar>
        <ion-buttons slot="start">
          <ion-menu-button></ion-menu-button>
        </ion-buttons>
        <ion-title>Home</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content fullscreen>
        ...
        </ion-list>
      </div>
    </ion-content>
  </ion-page>
</template>

我们使用了与其他页面相同的标题,但这个页面有不同的标题。

然后,我们渲染historyWithTypes计算属性来渲染历史记录中的项目。如果type属性设置为tip,我们渲染小费计算数据。否则,我们显示货币转换数据。在每一行中,我们有打开按钮,当我们点击它时调用go()方法。这将带我们到页面,该页面由CurrencyCoverterTipsCalculatorwatchers填充了历史记录中给定的数值。删除按钮调用deleteEntry()方法,通过其索引删除条目。我们必须记得为每个条目设置key属性为唯一 ID,以便 Vue 可以正确跟踪它们。

接下来,我们将通过编写以下代码来添加导入:

<script lang="ts">
import {
  IonButtons,
  IonContent,
  IonHeader,
  IonMenuButton,
  IonPage,
  IonTitle,
  IonToolbar,
  IonLabel,
  IonButton,
  IonList,
  IonItem,
} from "@ionic/vue";
import { useStore } from "vuex";
import { computed } from "vue";
import { CurrencyConversion, TipCalculation } from 
 "@/interfaces";
import { useRouter } from "vue-router";
...
</script>

然后,我们将为我们的历史记录条目添加type别名,并通过编写以下代码注册组件代码:

<script lang="ts">
...
type HistoryEntry = CurrencyConversion | TipCalculation;
export default {
  name: "Home",
  components: {
    IonButtons,
    IonContent,
    IonHeader,
    IonMenuButton,
    IonPage,
    IonTitle,
    IonToolbar,
    IonLabel,
    IonButton,
    IonList,
    IonItem,
  },
  ...
};
</script>

我们创建HistoryEntry TypeScript 类型别名,它是CurrencyConversionTipCalculation接口的联合。HistoryEntry类型的对象必须具有CurrencyConversionTipCalculation接口的结构。然后,我们像注册其他组件一样注册组件。

接下来,我们将添加setup()方法来添加组件的逻辑:

<script lang="ts">
...
export default {
  ...
  setup() {
    const store = useStore();
    const router = useRouter();
    const history = computed(() => store.state.history);
    const historyWithTypes = computed(() => {
      return history.value.map((history: HistoryEntry): 
        HistoryEntry & {
        type: string;
      } => {
        if ("subtotal" in history) {
          return {
            ...history,
            type: "tip",
          };
        }
        return {
          ...history,
          type: "conversion",
        };
      });
    });
    const go = (history: HistoryEntry & { type: string }) 
     => {
      const { type, id } = history;
      if (type === "tip") {
        router.push({ path: "/tips-calculator", query: { id
         } });
      } else {
        router.push({ path: "/currency-converter", query: { 
         id } });
      }
    };
    const deleteEntry = (index: number) => {
      store.commit("removeHistoryEntry", index);
    };
    return {
      history,
      historyWithTypes,
      go,
      deleteEntry,
    };  
  },
};
</script>

我们像往常一样使用 useStoreuseRouter 获取存储和路由。然后,我们使用 Vuex 存储中的 history 计算属性获取历史状态。然后,我们使用 history 计算属性创建 historyWithTypes 计算属性。这使我们可以向对象添加 type 属性,以便我们可以轻松地在模板中区分项目的类型。在 map 回调中,我们将返回类型设置为 HistoryEntry & { type: string },以创建与 HistoryEntry 组成的接口和 { type: string } 类型的交集类型。HistoryEntry & { type: string } 与 CurrencyConversion & { type: string } | TipCalculation & { type: string } 相同,因为 & 运算符在与联合 (|) 运算符一起使用时会分发。

go() 方法允许我们使用 id 属性作为 id 查询参数的值导航到正确的页面,当我们调用 router.push 时。path 属性包含了我们在路由定义中指定的 URL 路径,而 query 属性包含了用于在路径之后形成查询字符串的对象。

deleteEntry() 方法允许我们通过提交 removeHistoryEntry 突变来删除条目。

我们返回所有方法和计算属性,以便它们可以在模板中使用。主页 应该如下截图所示:

图 5.4 – 主屏幕

图 5.4 – 主屏幕

创建 Vuex 存储

现在,我们需要创建 Vuex 存储。为此,我们将创建 src/vue/index.ts 文件并编写以下代码:

import { createStore } from 'vuex'
import createPersistedState from "vuex-persistedstate";
import {
  CurrencyConversion,
  TipCalculation
} from '../interfaces'
type HistoryEntry = CurrencyConversion | TipCalculation
const store = createStore({
  plugins: [createPersistedState()],
  state() {
    return {
      history: []
    }
  },
  mutations: {
    addToHistory(state: { history: HistoryEntry[] }, entry:
      HistoryEntry) {
      state.history.push(entry)
    },
    removeHistoryEntry(state: { history: HistoryEntry[] },
     index: number) {
      state.history.splice(index, 1)
    },
  }
})
export default store

在这里,我们有与 Home.vue 相同的接口和类型别名。我们使用 createStore 函数创建了 Vuex 存储。plugins 属性设置为 createPersistedState 函数返回的数组,以便我们将存储状态保存到本地存储中。我们在 state() 方法中有历史状态。mutations() 方法有 addToHistory 突变,它允许我们向历史数组状态添加条目。我们还有 removeHistoryEntry,它允许我们根据索引从历史状态中删除历史项目。我们必须记得在最后导出存储,以便我们以后可以导入它。

然后,我们需要添加国家和货币列表。为了添加它们,我们将创建 src/constants.ts 文件并添加以下代码:

import { Choice } from "./interfaces";
export const countries: Choice [] = [
  {
    "name": "Afghanistan",
    "abbreviation": "AF"
  },
  {
    "name ": "Åland Islands",
    "abbreviation": "AX"
  },
  ...
]
export const currencies: Choice[] = [
  {
    "name": "United States Dollar",
    "abbreviation": "USD"
  },
  {
    "name": "Canadian Dollar",
    "abbreviation": "CAD"
  },
  {
    "name": "Euro",
    "abbreviation": "EUR"
  },
]

完整的文件内容可以在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter05/src/constants.ts找到。

现在,我们将通过添加src/interfaces.ts文件并添加以下代码来添加我们导入的接口:

export interface Choice {
  name: string,
  abbreviation: string
}
export interface CurrencyConversion {
  id: string,
  amount: number,
  fromCurrency: string,
  toCurrency: string,
}
export interface TipCalculation {
  id: string,
  subtotal: number,
  country: string,
}

main.ts中,我们必须通过编写以下代码向我们的应用程序添加存储:

...
const app = createApp(App)
  .use(IonicVue)
  .use(router)
  .use(store);
...

我们添加了.use(store),这样我们就可以在我们的应用程序中使用存储。

最后,在App.vue中,我们必须更新左侧菜单的项目。在模板中,我们必须编写以下内容:

<template>
  <IonApp>
    <IonSplitPane content-id="main-content">
      <ion-menu content-id="main-content" type="overlay">
        <ion-content>
          <ion-list id="unit-list">
            <ion-list-header>Calculator</ion-list-header>
            <ion-menu-toggle
...
      <ion-router-outlet id="main-content"></ion-router-
        outlet>
    </IonSplitPane>
  </IonApp>
</template>

ion-menu-toggle组件包含我们可以点击或轻触以转到给定页面的菜单项,由router-link属性指定。ion-router-outlet组件是我们之前创建的页面所在的位置。ion-icon组件让我们显示每个条目的图标。

接下来,我们将通过编写以下代码为App.vue添加导入项:

<script lang="ts">
import {
  IonApp,
  IonContent,
  IonIcon,
  IonItem,
  IonLabel,
  IonList,
  IonListHeader,
  IonMenu,
  IonMenuToggle,
  IonRouterOutlet,
  IonSplitPane,
} from "@ionic/vue";
import { computed, defineComponent, ref, watch } from 
 "vue";
import { RouterLink, useLink, useRoute } from "vue-router";
import { cashOutline, homeOutline } from "ionicons/icons";
...
</script>

现在,我们将通过编写以下代码添加组件逻辑:

export default defineComponent({
  name: "App",
  components: {
    IonApp,
    IonContent,
    IonIcon,
    IonItem,
    IonLabel,
    IonList,
    IonListHeader,
    IonMenu,
    IonMenuToggle,
    IonRouterOutlet,
    IonSplitPane,
  },
  setup() {
    const selectedIndex = ref(0);
    const appPages = [
      ...
      {
        title: "Tips Calculator",
        url: "/tips-calculator",
        iosIcon: cashOutline,
        mdIcon: cashOutline,
      },
    ];
    const route = useRoute();
    return {
      selectedIndex,
      appPages,
      cashOutline,
      route,
    };
  },
});

在这里,我们注册了组件并添加了appPages属性来渲染项目。它不是一个响应式属性,因为我们没有使用 reactive 创建它,但是我们可以在模板中使用它,因为我们返回了它。现在,我们将通过编写以下代码添加一些样式:

<style scoped>
...
.selected {
  font-weight: bold;
}
</style>

接下来,我们将通过编写以下代码添加一些全局样式:

<style>
ion-menu-button {
  color: var(--ion-color-primary);
}
#container {
  text-align: center;
  position: absolute;
  left: 0;
  right: 0;
}
#container strong {
  font-size: 20px;
  line-height: 26px;
}
#container p {
  font-size: 16px;
  line-height: 22px;
  color: #8c8c8c;
  margin: 0;
}
#container a {
  text-decoration: none;
}
</style>

通过创建项目,我们学会了如何使用 Composition API,这是 Ionic 用来创建 Vue 项目的。我们还学会了如何在 JavaScript 代码中添加 TypeScript 类型注释,以防止代码中的数据类型错误。最后,我们学会了如何从 Web 应用程序创建移动应用程序。

总结

使用 Ionic Vue,我们可以轻松地使用 Vue 3 创建移动应用程序。它利用组合 API、TypeScript 和 Vue Router,以及 Ionic 提供的组件,创建出外观良好的应用程序,可以作为 Web 应用程序或移动应用程序运行。它还配备了所有必要的工具,可以在设备或模拟器中预览应用程序,并将其构建成应用程序包,我们可以部署到应用商店。

使用 Composition API,我们可以像使用 Vue Options API 一样添加逻辑,但我们可以使用函数而不是引用它们来添加所有逻辑。Ionic Vue 还将 TypeScript 作为组件的默认语言。这使我们能够在编译时防止类型错误,减少类型错误在运行时发生的几率。这是一个方便的功能,可以减少 JavaScript 开发中的挫败感。我们利用接口、联合和交集类型以及类型别名来定义对象的类型。

在下一章中,我们将看看如何使用 PrimeVue 和 Express 构建旅行预订应用程序。

第六章:使用 PrimeVue UI 框架构建度假预订应用

第五章中,使用 Ionic 构建多用途计算器移动应用,我们使用 Ionic 移动应用框架构建了一个移动应用,该框架构建在 Vue.js 之上。然而,到目前为止,在本书中,我们尚未使用基于 Vue.js 的 UI 库或框架构建任何 Web 应用程序。此外,我们还没有构建具有自己后端的任何内容。大多数系统肯定需要后端,因为我们需要一个地方来存储我们的数据、验证用户、运行后台任务等。在本章中,我们将使用 PrimeVue UI 框架构建度假预订应用。

我们将使用 Vue 3 前端进行管理,另一个前端供用户添加他们的预订。我们还将包括一个简单的后端,以在管理员执行只有他们才能完成的任务之前对其进行身份验证。为了尽可能简化项目,普通公众的前端不需要身份验证。

在本章中,我们将专注于以下主题:

  • 使用 PrimeVue UI 框架构建前端

  • 使用 Express 构建简单的后端进行身份验证

  • 使用 SQLite 在后端持久化数据

  • 在前端使用 Vue Router 进行身份验证

  • 使用 Vee-Validate 和 Yup 进行表单验证

技术要求

本章的代码位于github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter06

PrimeVue是基于 Vue 3 的 UI 框架。这意味着我们可以将其用于 Vue 3 应用程序。基于 Vue 2 的框架无法用于 Vue 3 应用程序,因为 API 已经经历了重大变化。Vue 3 的底层代码也与 Vue 2 不同。PrimeVue 包括许多我们在 Web 应用程序中使用的常见组件,如文本输入、按钮、菜单栏、表格等。它在包含的内容方面非常全面。此外,它还提供了主题样式。这意味着我们可以立即使用内置组件。由于 PrimeVue 是为 Vue 3 而制作的,我们可以简单地注册组件、导入 CSS,并在我们的代码中使用组件。我们还可以根据需要为给定组件在本地或全局注册它们。

理解 PrimeVue

PrimeVue 带有输入框和各种文本的样式,例如验证错误和按钮。它还带有 flexbox 助手,我们可以使用它们轻松设置组件的间距。这非常有用,因为我们可以简单地使用 PrimeVue 提供的 CSS 类来设置组件的位置和间距。

到目前为止,在本书中,我们还没有使用任何库来使表单验证更加方便。表单验证是大多数 Web 应用程序中我们必须经常做的事情。

理解 Vee-Validate 和 Yup

Vee-Validate 4是与 Vue 3 兼容的表单验证库。借助它,我们可以添加组件,以在前端对我们的表单进行验证。我们将与 Yup 数据验证库一起使用它,以创建表单验证模式,Vee-Validate 可以用于验证。通过Form组件,我们可以添加一个表单,使我们能够使用 Vee-Validate 4 进行表单验证。然后,Field组件可以用于验证 PrimeVue 提供的表单控件组件。它通过包装它们并将Field组件提供的插槽属性作为输入组件的属性传递来实现这一点。

Yup 库将与 Vee-Validate 一起使用,让我们可以轻松验证表单值,而无需从头开始编写所有代码。它允许我们创建表单验证模式对象,我们可以将其传递到使用 Vee-Validate 创建的表单中,以添加表单验证。

理解 Express

为了创建一个简单的后端来存储数据,我们使用Express 框架。这是一个非常简单的 Node.js 后端框架,可以让我们快速创建一个简单的后端。为了存储数据,我们将使用 SQLite 数据库来保持项目简单。我们将使用 Express 框架创建一个 API,以便前端可以向其发出 HTTP 请求。我们通过公开 API 端点来让他们发出请求,前端可以通过添加路由来使用它。每个路由都有一个处理程序函数,用于处理前端提交的数据。我们从前端发出的 HTTP 请求中获取请求数据,其中包括头部和主体,并在路由处理程序中使用它们来获取和存储我们想要的数据。

连接前端和后端

为了使前端应用与后端应用通信,我们需要在后端启用跨域通信,以便来自前端的流量可以通过到达后端。这可以通过我们将添加到 Express 应用程序的跨域资源共享CORS)中间件轻松完成。

为了使用 SQLite 数据库,我们使用sqlite3库,它允许我们在 Node.js 应用程序中操作 SQLite 数据库。我们可以进行查询并运行 SQL 命令来插入或删除数据库中的数据。

此外,我们将为管理员前端设置简单的身份验证。我们将检查管理员登录的用户名和密码,如果有效,我们可以发放一个令牌并将其发送到前端。然后,前端将使用存储在标头中的令牌来检查请求是否可以从前端发出。我们仅为仅限管理员路由添加身份验证,因此我们只需要在加载需要身份验证的路由之前检查令牌。

为了创建和检查令牌,我们使用jsonwebtoken库。这允许我们创建一个令牌并用秘密字符串签名。它还使我们能够使用秘密检查令牌是否有效。我们将jsonwebtoken库放在一个中间件中,在路由处理程序之前运行以进行检查。

如果令牌有效,则调用函数继续到路由处理程序。否则,我们向客户端发送401状态。

现在,我们将构建项目。

创建度假预订项目

创建度假预订应用程序,我们需要为前端、管理员前端和后端创建子项目。我们使用 Vue CLI 来创建frontendadmin frontend项目脚手架。我们使用Express Generator全局包来创建backend文件夹。

按照以下步骤设置项目:

  1. 首先,创建travel-booking-app文件夹来容纳所有项目。

  2. 接下来,在主文件夹内创建admin-frontendfrontendbackend文件夹。

  3. 进入admin-frontend文件夹并运行以下命令:

npx vue create

这将在admin-frontend文件夹内添加 Vue 项目的脚手架代码。

  1. 如果要在当前文件夹中创建项目,请选择Y。然后,在要求选择项目的 Vue 版本时,选择Vue 3

同样,以相同的方式在frontend文件夹中运行 Vue CLI。

  1. 要创建一个 Express 项目,请运行 Express 应用程序生成器应用程序。要做到这一点,请进入backend文件夹并运行以下命令:
npx express-generator

上述命令将在backend文件夹中添加我们项目所需的所有文件。如果出现错误,请尝试以“管理员”身份运行express-generator

现在我们已经创建了项目的脚手架文件和文件夹,我们准备开始在后端上工作。

创建后端

现在我们已经创建了带有脚手架代码的项目文件夹,我们可以开始编写项目代码了。我们将从后端开始,因为我们需要它用于两个前端。

要开始,让我们添加一些需要用来操作 SQLite 数据库并向我们的应用程序添加身份验证的库。此外,我们需要一个库来向我们的应用程序添加 CORS。

要安装它们,请运行以下命令:

npm i cors jsonwebtoken sqlite3

安装完这些包后,我们就可以开始编写代码了。

添加身份验证中间件

首先,我们添加我们将用来检查令牌的中间件。我们可以很容易地使用 jsonwebtoken 库来做到这一点。它具有 verify 方法来检查令牌。

要添加中间件,请在后端文件夹中创建middlewares文件夹,然后添加verify-token.js文件。接下来,添加以下中间件的代码:

const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
  const token = req.get('x-token')
  try {
    jwt.verify(token, 'secret');
    next()
  } catch (err) {
    res.status(401)
  }
}

在这里,我们使用req.get方法获取x-token请求头。然后,我们使用返回的tokensecret调用jwt.verify来验证令牌是否有效。然后,如果有效,我们调用next。如果无效,将抛出错误,并运行catch块。运行res.status401来将401响应返回给前端,因为在这种情况下令牌无效。

module.exports属性设置为middleware函数作为值,我们正在导出它。导出函数使其在我们后端应用程序的其他模块中可用。

添加处理请求的路由

接下来,我们将添加带有路由的router模块。首先,添加用于操作预订的路由。要做到这一点,请将bookings.js文件添加到routes文件夹中。在文件中,编写以下代码:

const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const router = express.Router();
const verifyToken = require('../middlewares/verify-token')
router.get('/', (req, res, next) => {
  const db = new sqlite3.Database('./db.sqlite');
  db.serialize(() => {
    db.all(`
      SELECT
        bookings.*,
        catalog_items.name AS catalog_item_name,
        catalog_items.description AS catalog_item_description
      FROM bookings
      INNER JOIN catalog_items ON catalog_items.id = 
        bookings.catalog_item_id
    `,
      [],
      (err, rows = []) => {
        res.json(rows)
      });
  })
  db.close();
});
...

在这里,我们导入所需的模块,包括我们刚刚创建的“verify-token 中间件”文件。

router.get方法允许我们创建一个 GET 请求 API 端点。路径在第一个参数中。它是路由的路径,相对于路由器的路径。因此,路由器的路由路径是第一个部分,而router.get的第一个参数中的路径形成 URL 的其余部分。

router.get方法的第二个参数是路由处理程序。req参数是一个包含请求数据的对象。res参数是一个对象,让我们可以向前端发送各种类型的响应。我们使用sqlite3.Database构造函数获取数据库,并指定数据库文件的路径。

接下来,我们调用db.serialize函数,以便可以按顺序运行回调中的代码。

db.all方法获取查询返回的所有结果。字符串是从 bookings 表中检索所有数据的 SQL 命令,我们将使用我们自己的 SQL 代码创建它。bookings表与catalog_items表连接,以便vacation套餐数据与预订相关联。

db.all的第二个参数是我们想要传递的额外参数,我们通过传入一个空数组来忽略它们。然后,在最后一个参数中,我们有一个带有err参数的函数,其中包含对象形式的错误。rows参数包含查询结果。在回调中,我们调用res.json返回带有rows参数数据的 JSON 响应。

然后,我们调用db.close来在所需操作完成后关闭数据库连接。

接下来,我们将创建一个 POST 路由。这将允许我们运行INSERT SQL 命令将条目插入到 bookings 表中。添加以下代码:

...
router.post('/', (req, res) => {
  const db = new sqlite3.Database('./db.sqlite');
  const { catalogItemId, name, address, startDate, endDate } = 
   req.body
  db.serialize(() => {
    const stmt = db.prepare(`
      INSERT INTO bookings (
        catalog_item_id,
        name,
        address,
        start_date,
        end_date
      ) VALUES (?, ?, ?, ?, ?)
    `);
    stmt.run(catalogItemId, name, address, startDate, endDate)
    stmt.finalize();
    res.json({ catalogItemId, name, address, startDate,
      endDate })
  })
  db.close();
});
...

在这里,我们使用req.body属性获取 body 属性。我们获取所有要插入条目的属性。接下来,我们使用INSERT语句创建一个预处理语句。最后的值是问号;这意味着它们是占位符,我们可以在运行stmt.run方法时放入我们自己的值。

准备好的语句很有用,因为它们使我们能够安全地传递值到我们的 SQL 命令中。这些值都经过了净化,以便恶意代码无法在代码中运行。我们运行stmt.run来运行带有我们想要替换占位符的值的准备好的语句。然后,我们调用stmt.finalize来通过写入数据来完成操作。接下来,我们调用res.json将 JSON 响应返回给前端作为响应。然后,我们调用db.close再次关闭数据库连接。

接下来,我们将使用router.delete方法创建一个DELETE端点。为此,请编写以下代码:

...
router.delete('/:id', verifyToken, (req, res) => {
  const db = new sqlite3.Database('./db.sqlite');
  const { id } = req.params
  db.serialize(() => {
    const stmt = db.prepare("DELETE FROM bookings WHERE id = (?)");
    stmt.run(id)
    stmt.finalize();
    res.json({ status: 'success' })
  })
  db.close();
});
...

在这里,我们有/:id路径。:id是路由的 URL 参数占位符。我们还有verifyToken中间件,我们在booking.js文件的顶部导入了它。我们可以使用它来在继续运行路由处理程序之前验证令牌。这意味着这个路由是一个需要在 API 端点调用的标头中需要令牌的经过身份验证的路由。

在路由处理程序中,我们从req.params属性中获取id URL 参数。然后,我们调用db.serialize,就像我们在之前的路由中所做的那样。在回调中,我们有准备好的语句,因此我们可以使用DELETE SQL 命令和我们在stmt.run方法中设置的id值。然后,我们调用stmt.finalizeres.jsondb.close,就像我们在其他路由中所做的那样。

最后,在booking.js文件的末尾,让我们添加以下内容:

module.exports= = router;

添加上述语句使我们能够将其导入到另一个文件中以注册路由。注册路由将使其可访问。

接下来,我们将在routes文件夹中创建catalog.js文件。这个文件是一个具有 API 端点的router模块,用于添加我们的度假套餐。首先,我们开始如下:

const express = require('express');
const router = express.Router();
const sqlite3 = require('sqlite3').verbose();
const verifyToken = require('../middlewares/verify-token')
router.get('/', (req, res,) => {
  const db = new sqlite3.Database('./db.sqlite');
  db.serialize(() => {
    db.all("SELECT * FROM catalog_items", [], (err, rows = []) 
      => {
      res.json(rows)
    });
  })
  db.close();
});
...

这几乎与bookings.js中的GET路由相同;但是,在这里,我们从catalog_items表中检索所有项目。

接下来,让我们添加一个POST路由来将条目添加到catalog_items表中。编写以下代码:

...
router.post('/', verifyToken, (req, res,) => {
  const { name, description, imageUrl } = req.body
  const db = new sqlite3.Database('./db.sqlite');
  db.serialize(() => {
    const stmt = db.prepare(`
    INSERT INTO catalog_items (
      name,
      description,
      image_url
    ) VALUES (?, ?, ?)
  `
    );
    stmt.run(name, description, imageUrl)
    stmt.finalize();
    res.json({ status: 'success' })
  })
  db.close();
});
...

在这里,我们在第二个参数中有verifyToken来检查此路由中的令牌,然后再运行第三个参数中的路由处理程序。

接下来,我们添加一个路由,使我们能够删除catalog_items条目。我们使用以下代码来实现这一点:

...
router.delete('/:id', verifyToken, (req, res,) => {
  const { id } = req.params
  const db = new sqlite3.Database('./db.sqlite');
  db.serialize(() => {
    const stmt = db.prepare("DELETE FROM catalog_items WHERE 
      id = (?)");
stmt.run(id)
stmt.finalize();
res.json({status:'success'})
db.close();
});
...

最后,我们导出路由:

module.exports = router;

这个模块与booking.js并没有太大的不同。

接下来,我们删除routes/users.js文件的内容,或者如果不存在,则创建它。然后,我们添加以下代码:

const express = require('express');
const jwt = require('jsonwebtoken');
const router = express.Router();
router.post('/login', (req, res) => {
  const { username, password } = req.body
  if (username === 'admin' && password === 'password') {
    res.json({ token: jwt.sign({ username }, 'secret') })
  }
  res.status(401)
});
module.exports= = router;

这是我们检查管理员用户的usernamepassword是否有效的地方。我们只有一个用户需要检查,以保持项目简单。我们从req.body对象中获取usernamepassword,该对象具有 JSON 请求对象。然后,我们使用if语句检查usernamepassword,如果if中的表达式返回true,我们调用jwt.sign使用第一个参数中的令牌数据和第二个参数中的secret创建令牌。然后,我们使用res.json返回带有认证令牌的响应。

否则,我们调用res.status401返回401响应,因为usernamepassword无效。

接下来,在app.js中注册我们的router模块和全局中间件。为此,我们编写以下代码:

...
const indexRouter = require('./routes/index');
const usersRouter = require('./routes/users');
const catalogRouter = require('./routes/catalog');
const bookingsRouter = require('./routes/bookings');
const app = express();
const cors = require('cors')
...
app.use('/users', usersRouter);
app.use('/catalog', catalogRouter);
app.use('/bookings', bookingsRouter);

我们导入了之前使用require导出的router模块。然后,我们导入了cors模块。

const cors = require('cors')

我们调用app.use添加cors中间件,然后添加router模块。在最后三行中,我们将path作为第一个参数,router模块作为第二个参数。这使我们能够访问之前创建的端点。使用cors模块,我们可以在 Express 应用程序中启用跨域通信。

接下来,让我们创建我们的 SQL 脚本,以便我们可以轻松地删除和创建表。为此,在backend文件夹中创建db.sql文件,并编写以下代码:

DROP TABLE IF EXISTS bookings;
DROP TABLE IF EXISTS catalog_items;
CREATE TABLE catalog_items (
  id INTEGER NOT NULL PRIMARY KEY,
  name TEXT NOT NULL,
  description TEXT NOT NULL,
  image_url TEXT NOT NULL
);
CREATE TABLE bookings (
  id INTEGER NOT NULL PRIMARY KEY,
  catalog_item_id INTEGER NOT NULL,
  name TEXT NOT NULL,
  address TEXT NOT NULL,
  start_date TEXT NOT NULL,
  end_date TEXT NOT NULL,
  FOREIGN KEY (catalog_item_id) REFERENCES catalog_items(id)
);

在这里,我们创建了bookingscatalog_items表。每个表都有各种字段。TEXT创建文本列。NOT NULL使列不可为空。PRIMARY KEY表示该列是主键列。FOREIGN KEY表示一个列是另一个列的外键。

我们可以通过安装 SQLite 程序的 DB 浏览器来运行 SQL 代码,可以在sqlitebrowser.org/下载,然后在backend文件夹中创建db.sqlite。然后,我们可以转到执行 SQL选项卡,并将代码粘贴到文本输入中。之后,我们可以选择所有文本,按下F5来运行代码。这将删除任何现有的bookingscatalog_items表,并重新创建它们。要将更改写入磁盘,您必须保存它们。要做到这一点,点击文件菜单,然后点击写入更改。我们也可以按下Ctrl + S键组合来保存更改。

最后,为了使我们的应用程序在更改代码时自动运行和重新启动,我们可以全局安装nodemon包。要做到这一点,请运行以下命令:

npm i -g nodemon

然后,在package.json文件中,将script.start属性的值更改为以下代码:

{
  ...
  "scripts": {
    "start": "nodemon ./bin/www"
  },
  ...
}

我们可以使用nodemon来运行npm start,而不是常规的 node 可执行文件,这意味着当我们更改任何代码文件并保存时,应用程序将自动重新启动。

现在我们已经为前端创建了一个基本的后端来消耗,我们可以继续使用 PrimeVue 和 Vue 3 创建我们的前端应用程序。

创建管理前端

现在后端应用程序已经完成,我们可以继续在管理前端上工作。我们之前已经在admin-frontend文件夹中为管理前端创建了 Vue 3 项目,因此我们只需要安装所需的包并处理代码。我们将需要 PrimeVue 包 - 即 Vee-Validate、Vue Router、Axios 和 Yup 包。

要安装它们,请在admin-frontend文件夹中运行以下命令:

npm i axios primeflex primeicons primevue@³.1.1 vee-validate@next vue-router@4 yup

Axios 允许我们向后端发出 HTTP 请求。Vue Router 允许我们将 URL 映射到page组件。Vee-Validate 和 Yup 允许我们轻松地向我们的表单添加表单验证,其余的包是 PrimeVue 包。

创建管理前端页面

安装完包后,我们可以开始处理代码。首先,我们将处理组件。在components文件夹中,添加CatalogForm.vue文件,并编写以下代码:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    <Field v-slot="{ field, errors }" v-model="name" 
      name="name">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <InputText
            placeholder="Name"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">
          Name is invalid.
        </small>
      </div>
    </Field>
    ...
  </Form>
</template>

在这里,我们有来自 Vee-Validate 包的Form组件,用于添加具有表单验证的表单。只有当所有表单值都有效时,才会触发submit事件。我们稍后将注册Form组件。validation-schema属性设置为 Yup 创建的验证模式对象。

Form组件内,我们有一个Field组件,这也是由 Vee-Validate 包提供的。稍后我们还将全局注册此组件,以便我们可以使用它。在Field组件内,我们有InputText组件,用于在我们的应用程序中添加输入字段。为了为InputText组件启用表单验证,我们将field对象传递给slot属性,并将整个内容作为v-bind指令的值。v-bind指令允许 Vee-Validate 处理表单值并向我们的form字段添加验证。errors数组提供了可能发生的任何验证错误。

p-col-12类由 PrimeVue 的 PrimeFlex 包提供。它允许我们将div标签的宽度设置为全宽,这意味着它占据页面上的 12 列中的 12 列。使用p-inputgroup类,我们可以创建一个输入组。p-error类将文本颜色样式设置为红色,以便我们可以以用户易于看到的方式显示表单验证消息。p-invalid类使输入的边缘变为红色。仅当错误的长度大于0时,我们才将其更改为红色,因为这意味着存在验证错误,而且仅在错误的长度大于0时才显示较小的元素。

Field组件具有v-model指令,将输入的值绑定到相应的响应式属性。我们还有一个name属性,也用作submit事件处理程序的value参数的属性名称,该处理程序具有输入的值。这些值始终有效,因为只有当所有表单值都有效时,才会运行提交处理程序。

使用name字段,我们可以输入度假套餐的名称。

接下来,我们需要添加一个文本区域,以允许用户为度假套餐输入描述。要做到这一点,请编写以下代码:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <Field v-slot="{ field, errors }" v-model="description" 
      name="description">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <Textarea
            placeholder="Description"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">
          Description is invalid
        </small>
      </div>
    </Field>
    ...
  </Form>
</template>

这与name字段几乎相同;但是,在这种情况下,我们将InputText组件替换为Textarea组件。我们还更改了v-modelname的值。Textarea组件来自 PrimeVue 包,它以自己的样式呈现为textarea元素。

接下来,我们添加图像 URL 字段,以便为度假套餐添加图像 URL。我们只需让用户输入图像 URL,以使我们的项目更简单。要将字段添加到Form组件中,请编写以下代码:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <Field v-slot="{ field, errors }" v-model="imageUrl" 
      name="imageUrl">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <InputText
            placeholder="Image URL"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">
          Image URL is invalid.
        </small>
      </div>
    </Field>
    ...
  </Form>
</template>

这只是另一个文本输入,名称和v-model值不同。最后,让我们使用以下代码向表单添加一个submit按钮:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <div class="p-col-12">
      <Button label="Add" type="submit" />
    </div>
  </Form>
</template>

Button组件来自 PrimeVue 包,我们稍后将全局注册它,以使其在任何地方都可用。

接下来,我们添加component选项对象。我们使用component选项 API 来创建我们的组件。首先,我们导入所有内容,并使用 Yup 库创建表单验证模式。要添加代码,请在components/CatalogForm.vue中编写以下内容:

<script>
import * as yup from "yup";
import axios from "axios";
import { APIURL } from "@/constants";
const schema = yup.object().shape({
  name: yup.string().required(),
  description: yup.string().required(),
  imageUrl: yup.string().url().required(),
});
export default {
  name: "BookingForm",
  data() {
    return {
      name: "",
      description: "",
      imageUrl: "",
      schema,
    };
  },
  ...
};
</script>

在这里,我们使用yup.object方法创建了模式对象,它允许我们验证具有一些属性的对象。validation模式与v-model绑定是分开的。我们传递给shape方法的对象属性必须与Field组件的name属性值匹配。

为了验证具有name设置为name的字段的值,我们将name属性设置为yup.string().required(),以确保name字段是一个字符串并且有值。我们为description设置相同的值。imageUrl值设置为yup.string().url().required(),以确保输入的值是一个 URL 并且已填写。

data方法返回模式,以便我们可以使用Form组件的validation-schema属性。

为了完成组件,我们添加了onSubmit方法,当Form组件发出submit事件时被调用:

<script>
...
export default {
  ...
  methods: {
    async onSubmit(value) {
      const { name, description, imageUrl } = value;
      await axios.post(`${APIURL}/catalog`, {
        name,
        description,
        imageUrl,
      });
      this.$emit("catalog-form-close");
    },
  },
};
</script>

在这里,我们只是从value参数中获取property值,该参数具有有效的表单字段值。然后,我们使用 JSON 负载进行 POST 请求到目录端点。随后,我们调用this.$emit方法来发出catalog-form-close事件,以通知对话框组件关闭此表单。

添加一个顶部栏和菜单栏

接下来,我们将在我们的应用程序中添加一个top bar组件。要做到这一点,在src/components文件夹中创建TopBar.vue。然后,将以下模板代码添加到文件中:

<template>
  <Menubar :model="items">
    <template #start>
      <b>Admin Frontend</b>
    </template>
  </Menubar>
</template>

Menubar组件由 PrimeVue 组件提供。我们可以使用它来添加一个菜单栏,其中包含一些我们可以点击以导航到不同页面的项目。model属性设置为items响应式属性,它是一个我们即将添加的菜单项对象数组。start插槽让我们可以在菜单栏的左侧添加项目。我们可以将一些粗体文本放入插槽中,它将显示在左侧。

接下来,我们可以为组件添加一个component对象。要添加它,请编写以下代码:

<script>
export default {
  name: "TopBar",
  props: {
    title: String,
  },
  data() {
    return {
      items: [
        {
          label: "Manage Bookings",
          command: () => {
            this.$router.push("/bookings");
          },
        },
...
  methods: {
    logOut() {
      localStorage.clear();
      this.$router.push("/");
    },
  },
  beforeMount() {
    document.title= = this.title;
  },
};
</script>

在这里,我们注册title属性,用它来设置document.title的值。document.title属性设置了顶部栏的标题。在data方法中,我们返回一个具有项目的响应式属性的对象。这被设置为一个具有labelcommand属性的对象。label属性显示在用户的菜单栏项目中。该项目显示为一个链接。当我们点击该项目时,command方法被运行。

通过this.$router.push方法,我们可以导航到与给定 URL 映射的页面。logOut方法导航回到与/路径映射的页面,这是我们稍后将讨论的login页面。另外,我们清除本地存储,以便清除身份验证令牌。

beforeMount钩子中,我们将document.title属性设置为title属性的值。

添加共享代码以处理请求

接下来,让我们编写 Axios 请求拦截器的代码,以便让我们将身份验证令牌添加到除了我们对/login端点进行请求之外的所有请求的x-token请求头中。为此,创建src/plugins文件夹,并将axios.js添加到其中。然后,在此文件中,编写以下代码:

import axios from 'axios'
import { APIURL } from '@/constants'
axios.interceptors.request.use((config) => {
  if (config.url.includes(APIURL) 
   && !config.url.includes('login')) {
    config.headers['x-token'] = localStorage.getItem('token')
    return config
  }
  return config
}, (error) => {
  return Promise.reject(error)
})

在这里,我们通过检索具有config.url属性的 URL 来检查正在进行请求的 URL。然后,如果我们对除/login之外的端点进行任何请求,我们设置x-token请求头:

config.headers['x-token'] = localStorage.getItem('token')

请注意,我们从本地存储中获取令牌,并将其设置为config.headers['x-token']的值。config.headers属性是一个具有请求头的对象。第二个参数是请求错误处理程序。在这里,我们只是返回一个带有Promise.reject的拒绝承诺,以便我们可以处理错误。

接下来,我们向我们的路由添加 Vue Router 路由。我们留在src/plugins文件夹中,并创建一个vue-router.js文件。然后,我们向文件中添加以下代码:

import { createWebHashHistory, createRouter } from 'vue-
 router'
import Login from '../views/Login.vue'
import Bookings from '../views/Bookings.vue'
import Catalog from '../views/Catalog.vue'
const beforeEnter = (to, from, next) => {
  try {
    const token = localStorage.getItem('token')
    if (to.fullPath !== '/' && !token) {
      return next({ fullPath: '/' })
    }
    return next()
  } catch (error) {
    return next({ fullPath: '/' })
  }
}
const routes = [
  { path: '/', component: Login },
  { path: '/bookings', component: Bookings, beforeEnter },
  { path: '/catalog', component: Catalog, beforeEnter },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
export default router

我们添加beforeEnter函数来检查前端页面上除首页之外的任何页面的令牌。我们可以使用to.fullPath属性检查用户尝试访问的路径。如果不是'/',并且本地存储中没有令牌,那么我们调用带有对象的next,并将fullPath属性设置为'/'以转到登录页面。否则,我们调用没有参数的next以转到我们应该去的页面。如果出现错误,那么我们也转到登录页面,正如您从catch块中的代码所看到的。

接下来,我们有包含路由定义的routes数组。这个数组中path属性是路由的路径,component是路径映射到的组件。beforeEnter属性被添加到最后两个路由对象,这样我们只能在登录后才能访问那里。

然后,为了创建router对象,我们使用带有history属性设置为createWebHashHistory函数返回的对象的对象调用createRouter。这样我们就可以在主机名和 URL 的其余部分之间保持哈希。我们将routes属性设置为routes数组,以便注册路由。这样我们就可以在访问路由时看到正确的组件。

最后,我们将router对象作为默认导出,以便稍后可以使用app.use将路由对象添加到我们的应用中。

接下来,我们在src文件夹内创建views文件夹。这意味着我们可以添加用户可以访问的页面。现在,让我们通过将Bookings.vue文件添加到src/views文件夹来添加一个页面,以允许管理员管理任何预订。我们打开文件并向组件添加以下模板。这样我们就可以添加之前创建的TopBar组件:

<template>
  <TopBar title="Manage Bookings" />
  <div class="p-col-12">
    <h1>Manage Bookings</h1>
  </div>
  <div class="p-col-12">
    <Card v-for="b of bookings" :key="b.id">
      <template #title>{{ b.name }} </template>
      <template #content>
        <p>Address: {{ b.address }}</p>
        <p>Description: {{ b.description }}</p>
        <p>Start Date: {{ b.start_date }}</p>
        <p>End Date: {{ b.end_date }}</p>
      </template>
      <template #footer>
        <Button
          icon="pi pi-times"
          label="Cancel"
          class="p-button-secondary"
          @click="deleteBooking(b.id)"
        />
      </template>
    </Card>
  </div>
</template>

请注意,我们使用h1元素添加页面标题。然后,我们添加Card组件来向管理员显示预订情况。Card组件由 PrimeVue 提供,我们稍后将注册它。我们使用v-for指令将预订数组渲染为多个Card组件。key属性设置为唯一 ID,以便 Vue 3 可以正确区分每个项目。

我们用不同的内容填充titlecontentfooter插槽。footer插槽有一个Button组件,当我们点击按钮时运行deleteBooking函数。icon属性允许我们在按钮的左侧设置图标。label属性在图标的右侧有按钮文本。通过p-button-secondary类,我们可以设置按钮的颜色。

接下来,我们可以添加component选项对象,其中包含getBookingdeleteBooking方法,分别通过后端 API 检索预订和删除预订。要添加它们,请编写以下代码:

<script>
import axios from "axios";
import { APIURL } from "@/constants";
import TopBar from "@/components/TopBar";
export default {
  name: "Bookings",
  components: {
    TopBar,
  },
  data() {
    return {
      bookings: [],
    };
  },
...
  beforeMount() {
    this.getBookings();
  },
};
</script>

我们还在components属性中注册了TopBar组件。getBookings方法调用axios.get发出 GET 请求,并将this.bookings的值设置为响应对象的响应式属性。

bookings存储在返回的解析值的对象的data属性中。

同样,我们在deleteBooking方法中调用axios.delete发出DELETE请求以删除项目。然后,我们调用this.getBookings再次获取数据。我们还在beforeMount钩子中调用this.getBookings,以在页面加载时获取数据。

接下来,我们添加一个页面,允许管理员管理度假套餐项目。为此,让我们将Catalog.vue文件添加到src/views文件夹中。然后,在文件中,编写以下内容:

<template>
  <TopBar title="Manage Vacation Packages" />
  <div class="p-col-12">
    <h1>Manage Vacation Packages</h1>
  </div>
  <div class="p-col-12">
    <Button label="Add Vacation Package" 
      @click="displayCatalog= = true" />
    <Dialog header="Add Vacation Package" v-
      model:visible="displayCatalog">
      <CatalogForm
        @catalog-form-close="
          displayCatalog= = false;
          getCatalog();
        "
      />
    </Dialog>
  </div>
  ...
</template>

在这里,我们添加TopBar组件来显示顶部栏;h1显示一个标题。接下来,我们添加一个按钮,通过将displayCatalog设置为true来显示对话框。然后,我们通过将v-model指令与可见修饰符设置为displayCatalog值来显示Dialog组件。使用这个,我们可以控制Dialog组件何时显示。Dialog组件显示一个对话框,这个组件由 PrimeVue 提供。

header属性设置对话框框的标题文本。我们使用CatalogForm作为内容,并监听CatalogForm组件发出的catalog-form-close事件。当它被发出时,我们将displayCatalog设置为false,并调用getCatalog再次获取数据:

<template>
  ...
  <div class="p-col-12">
    <Card v-for="c of catalog" :key="c.id">
      <template #header>
        <img :alt="c.description" :src="c.image_url" />
      </template>
      <template #title> {{ c.name }} </template>
      <template #content>
        {{ c.description }}
      </template>
      <template #footer>
        <Button
          icon="pi pi-times"
          label="Delete"
          class="p-button-secondary"
          @click="deleteCatalogItem(c.id)"
        />
      </template>
    </Card>
  </div>
</template>

接下来,我们添加从目录响应属性渲染的Card组件,使用v-for指令来渲染目录条目。其余代码与我们在Bookings.vue文件中的代码类似,但现在渲染属性不同,当我们点击它时,Button调用不同的方法。

接着,我们通过向src/views/Catalog.vue添加以下代码来添加组件对象:

<script>
import axios from "axios";
import { APIURL } from "@/constants";
import TopBar from "@/components/TopBar";
import CatalogForm from "@/components/CatalogForm";
...
  methods: {
    async getCatalog() {
      const{ { data } = await axios.get(`${APIURL}/catalog`);
      this.catalog = data;
    async deleteCatalogItem(id) {
      await axios.delete(`${APIURL}/catalog/${id}`);
      this.getCatalog();
    },
  },
  beforeMount() {
    this.getCatalog();
  },
};
</script>

在这里,代码与我们在src/views/Bookings.vue中的代码类似,只是在这里,我们向目录端点发出请求以获取和删除数据。

然后,我们在管理前端应用程序中创建最后一个页面,即登录页面。要添加登录页面,我们将Login.vue文件添加到src/views文件夹中。然后,在文件内部,我们使用以下代码添加formusername字段:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    <div class="p-col-12">
      <h1>Admin Log In</h1>
    </div>
    <Field v-slot="{ field, errors }" v-model="username" 
      name="username">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <InputText
            placeholder="Username"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">
          Username is invalid.
        </small>
      </div>
    </Field>
    ...
  </Form>
</template>

username字段与我们之前添加的所有其他字段类似。接下来,我们使用以下代码添加password输入和按钮:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <Field v-slot="{ field, errors }" v-model="password" 
      name="password">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <InputText
            placeholder="Password"
            type="password"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">
          Password is invalid
        </small>
      </div>
    </Field>
    <div class="p-col-12">
      <Button label="Log In" type="submit" />
    </div>
  </Form>
</template>

我们将type属性设置为password,使字段成为密码输入。按钮的type属性设置为submit,以便在单击按钮时触发submit事件,并且所有表单值保持有效。

接下来,我们添加Login.vue文件的组件对象部分,其中包含onSubmit方法来发起登录请求:

<script>
import * as yup from "yup";
import axios from "axios";
import { APIURL } from "@/constants";
const schema = yup.object().shape({
  username: yup.string().required(),
  password: yup.string().required(),
});
export default {
  name: "Login",
  data() {
    return {
      username: "",
      password: "",
      schema,
    };
  },
  methods: {
    async onSubmit(values) {
      const { username, password } = values;
      try {
        const {
          data: { token },
        } = await axios.post(`${APIURL}/users/login`, {
          username,
          password,
        });
        localStorage.setItem("token", token);
        this.$router.push("/bookings");
      } catch (error) {
        alert("Login failed");
      }
    },
  },
};
</script>

我们创建了验证模式的schema对象,它类似于我们之前使用的其他模式。然后,我们将其添加到data方法中返回的对象中。onSubmit方法从value参数中获取usernamepassword属性,以便我们可以将其用于向/users/login端点发起 POST 请求。

完成后,如果请求成功,我们从响应中获取一个令牌,以及localStorage.setItem方法。接下来,我们调用this.$router.push方法重定向到/bookings URL。如果有任何错误,我们将显示一个带有“登录失败”消息的警报。

接下来,我们将router-view组件添加到App.vue中,这样我们就可以显示在routes对象中创建的页面。要添加它,编写管理前端:共享代码,添加以处理请求的代码:

<template>
  <router-view></router-view>
</template>
<script>
export default {
  name: "App",
};
</script>
<style>
body {
  background-color: #ffffff;
  font-family: -apple-system, BlinkMacSystemFont, Segoe UI,
    Roboto, Helvetica,
    Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, 
      Segoe UI Symbol;
  font-weight: normal;
  color: #495057;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  margin: 0px;
}
</style>

我们还有一个style标签来设置字体系列,并将页面的边距设置为0px,这样元素与页面边缘之间就没有空白。

接下来,我们将constants.js添加到src文件夹,然后将APIURL添加到其中:

export const APIURL = 'http://localhost:3000'

main.js中,我们注册了所有全局组件以及之前创建的路由对象。我们还导入了 PrimeVue 提供的全局样式,所以一切看起来都很好:

import { createApp } from 'vue'
import App from './App.vue'
import PrimeVue from 'primevue/config';
import InputText from "primevue/inputtext";
import Button from "primevue/button";
import Card from 'primevue/card';
import Toolbar from 'primevue/toolbar';
import router from './plugins/vue-router'
import Textarea from 'primevue/textarea';
import Dialog from 'primevue/dialog';
import Menubar from 'primevue/menubar';
import { Form, Field } from "vee-validate";
import "primeflex/primeflex.css";
import 'primevue/resources/themes/bootstrap4-light-blue/theme.css'
import "primevue/resources/primevue.min.css";
import "primeicons/primeicons.css";
import './plugins/axios'
...
app.component("Form", Form);
app.component("Field", Field);
app.use(PrimeVue);
app.use(router)
app.mount('#app')

package.json中,通过将script.serve属性更改为以下内容,更改开发服务器运行的端口:

{
  ...
  "scripts": {
    "serve": "vue-cli-service serve --port 8082",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  ...
}

现在,当我们运行npm run serve时,我们会得到以下截图:

图 6.1 - 管理前端

图 6.1 - 管理前端

现在我们已经创建了管理前端应用程序,我们唯一需要添加的是用户前端。

创建用户前端

现在我们已经完成了管理前端,我们将通过创建用户前端来完成本章的项目。用户前端与管理前端类似;但是,在这种情况下,不需要身份验证即可使用。

我们将从安装与管理前端相同的软件包开始。转到前端文件夹并运行以下命令:

npm i axios primeflex primeicons primevue@³.1.1 vee-validate@next vue-router@4 yup

接下来,如果不存在,创建src/components文件夹。然后,在src/components中创建BookingForm.vue文件,以便用户可以添加他们的预订。

添加formname字段,以允许用户输入他们的姓名:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    <Field v-slot="{ field, errors }" v-model="name" 
      name="name">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <InputText
            placeholder="Name"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">Name 
          is invalid </small>
      </div>
    </Field>
    ...
  </Form>
</template>

这与我们之前添加的其他文本输入字段非常相似。然后,使用以下代码添加地址字段:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <Field v-slot="{ field, errors }" v-model="address" 
      name="address">
      <div class="p-col-12">
        <div class="p-inputgroup">
          <InputText
            placeholder="Address"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0"
          >Address is invalid</small
        >
      </div>
    </Field>
    ...
  </Form>
</template>

现在,让我们添加 PrimeVue 提供的Calendar组件,这是我们在此项目中以前未使用过的。Calendar组件允许用户选择日期。我们可以添加开始日期字段,以允许用户选择他们假期的开始日期:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <Field v-slot="{ field, errors }" v-model="startDate"         name="startDate">
      <div class="p-col-12">
        <label>Start Date</label>
        <div class="p-inputgroup">
          <Calendar
            inline
            placeholder="Start Date"
            :class="{ 'p-invalid': errors.length > 0 }"
            :minDate="new Date()"
            v-bind="field"
            v-model="startDate"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0">
          Start date is invalid
        </small>
      </div>
    </Field>
    ...
  </Form>
</template>

在这里,我们有minDate属性,它设置用户可以选择的最早日期。inline属性将使日期选择器显示在表单上,而不是在弹出窗口中。同样,我们可以使用以下代码添加结束日期字段:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <Field v-slot="{ field, errors }" v-model="endDate"         name="endDate">
      <div class="p-col-12">
        <label>End Date</label>
        <div class="p-inputgroup">
          <Calendar
            inline
            placeholder="End Date"
            :class="{ 'p-invalid': errors.length > 0 }"
            v-bind="field"
            v-model="endDate"
            :minDate="new Date(+startDate + 24 * 3600 * 1000)"
          />
        </div>
        <small class="p-error" v-if="errors.length > 0"
          >End date is invalid</small
        >
      </div>
    </Field>
    ...
  </Form>
</template>

在这里,我们将minDate属性设置为startDate后的一天。24 * 3600 * 1000毫秒等于一天。最后,我们添加submit按钮,就像我们在其他表单中所做的那样:

<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    ...
    <div class="p-col-12">
      <Button label="Book" type="submit" />
    </div>
    ...
  </Form>
</template>

接下来,我们通过编写以下内容创建schema

<script>
import { Form, Field } from "vee-validate";
import * as yup from "yup";
import axios from "axios";
import { APIURL } from "@/constants";
const schema = yup.object().shape({
  name: yup.string().required(),
  address: yup.string().required(),
  startDate: yup.date().required().min(new Date()),
  endDate: yup
    .date()
    .required()
    .when(
      "startDate",
      (startDate, schema) => startDate && 
        schema.min(startDate)
    ),
});
...
</script>

为了验证endDate,我们使用when方法调用要检查的field名称。然后,我们调用schema.min来确保endDate晚于startDate

接下来,我们添加component对象来注册selectedCatalogId属性并添加onSubmit方法。我们编写以下代码:

<script>
...
export default {
  name: "BookingForm",
  components: {
    Form,
    Field,
  },
  props: {
...
  methods: {
    async onSubmit(values) {
      const { name, address, startDate, endDate } = values;
      await axios.post(`${APIURL}/bookings`, {
        name,
        address,
        startDate,
        endDate,
        catalogItemId: this.selectedCatalogId,
      });
      this.$emit("booking-form-close");
    },
  },
};
</script>

onSubmit方法从values参数中获取表单字段值,并向 bookings 端点发出 POST 请求以添加预订。我们使用selectedCatalogId来添加预订。然后,我们发出booking-form-close事件,向父级发出事件,以信号表单关闭。

接下来,我们通过将vue-router.js添加到src/plugins文件夹中,向我们的应用程序添加 Vue Router:

import { createWebHashHistory, createRouter } from 'vue-router'
import Catalog from '../views/Catalog.vue'
const routes = [
  { path:'/', component: Catalog },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
export default router

这与我们在管理前端中所做的非常相似。

接下来,我们创建一个页面,通过添加src/views/Catalog.vue文件,然后添加以下模板代码来向用户展示所有的度假套餐:

<template>
  <Card v-for="c of catalog" :key="c.id">
    <template #header>
      <img :alt="c.description" :src="c.image_url" />
    </template>
    <template #title> {{ c.name }} </template>
    <template #content>
      {{ c.description }}
    </template>
    <template #footer>
      <Button
        icon="pi pi-check"
        label="Book"
        class="p-button-secondary"
        @click="book(c.id)"
      />
...
          :selectedCatalogId="selectedCatalogId"
        />
      </Dialog>
    </template>
  </Card>
</template>

在这里,我们只是从目录数组中呈现一个表单。我们有一个带有BookingForm组件的Dialog组件。我们监听由其发出的booking-form-close事件,通过将selectedCatalogId作为同名 prop 的值传递给其来关闭Dialog组件,将displayBookingForm设置为false并调用displayMessage来显示警报。

模板代码的其余部分几乎与我们之前的相同,除了显示的属性名称和标题插槽中图像的添加。

接下来,我们通过编写以下代码将component选项对象添加到同一文件中:

<script>
import axios from "axios";
import { APIURL } from "@/constants";
import BookingForm from "../components/BookingForm.vue";
export default {
  name: "Catalog",
  components: {
    BookingForm,
  },
  data() {
    return {
      selectedCatalogId: undefined,
      displayBookingForm: false,
...
    displayMessage() {
      alert("Booking successful");
    },
  },
  beforeMount() {
    this.getCatalog();
  },
};
</script>

我们在components属性中注册BookingForm组件。getCatalog函数从 API 获取度假套餐目录项。booking函数将displayBookingForm设置为true以打开Dialog组件,并在那里也设置了selectedCatalogIdbeforeMount钩子调用getCatalog以检索目录数据。

添加路由视图和入口点代码

App.vue中,我们编写以下代码来添加router-view并设置与我们在管理前端中相同的样式:

<template>
  <router-view></router-view>
</template>
<script>
export default {
  name: "App",
};
</script>
<style>
body {
  background-color: #ffffff;
  font-family: -apple-system, BlinkMacSystemFont, Segoe UI,
    Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, 
     Segoe UI Emoji, Segoe UI Symbol;
  font-weight: normal;
  color: #495057;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  margin: 0px;
}
</style>

然后,我们在src文件夹中创建constants.js,并添加以下行来添加APIURL

export const APIURL = 'http://localhost:3000'

然后,在main.js中,我们用以下代码替换文件的内容,以全局注册组件和路由。我们还导入了 PrimeVue 提供的样式,让我们的应用程序看起来很好:

import { createApp } from 'vue'
import App from './App.vue'
import PrimeVue from 'primevue/config';
import InputText from "primevue/inputtext";
import Button from "primevue/button";
import Card from 'primevue/card';
import Toolbar from 'primevue/toolbar';
import Calendar from 'primevue/calendar';
import Dialog from 'primevue/dialog';
import router from './plugins/vue-router'
import "primeflex/primeflex.css";
import 'primevue/resources/themes/bootstrap4-light-blue/theme.css'
import "primevue/resources/primevue.min.css";
import "primeicons/primeicons.css";
const app = createApp(App)
app.component("InputText", InputText);
app.component("Button", Button);
app.component("Card", Card);
app.component("Toolbar", Toolbar);
app.component("Calendar", Calendar);
app.component("Dialog", Dialog);
app.use(PrimeVue);
app.use(router)
app.mount('#app')

package.json中,我们通过将script.serve属性更改为以下内容来更改开发服务器运行的端口:

{
  ...
  "scripts": {
    "serve": "vue-cli-service serve --port 8082",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  ...
}

现在,当我们运行npm run serve时,我们会得到以下截图:

图 6.2 - 用户前端

图 6.2 - 用户前端

用户前端创建完成后,度假预订系统现在已经完成。

总结

在本章中,我们学习了如何有效地使用 PrimeVue 来构建一个度假预订应用程序。通过 PrimeVue,我们可以轻松创建漂亮的 Vue 3 web 应用程序。PrimeVue 带有许多有用的组件,我们可以添加到我们的 web 应用程序中,比如输入框、文本区域、表格、对话框、日期选择器等等。它还内置了样式,因此我们不必自己从头开始添加任何样式。此外,我们还可以添加 PrimeVue 提供的 PrimeFlex 包;通过 flexbox,我们可以轻松地改变元素和组件的间距和位置。

Vee-Validate 和 Yup 允许我们在 Vue 3 应用程序中添加表单验证。这与 PrimeVue 提供的输入组件很容易集成。这两个库使得大部分表单验证工作变得简单,因为我们不必自己编写所有的表单验证代码。

为了创建一个简单的后端,我们使用 Express 创建了一个简单的 API 来与前端交互。我们还使用了sqlite3包来操作我们 API 中的 SQLite 数据库。Express 带有许多附加组件,我们可以使用它们来添加许多功能,比如跨域通信。我们还可以通过jsonwebtoken库很容易地向我们的 Express 应用程序添加 JSON Web Token 身份验证。

在下一章中,我们将学习如何使用 GraphQL 和 Vue 3 构建一个商店前端。