Skip to content

18. 税务计算服务器的客户端 Vue.js

18.1. Architecture

我们将实现一个具有以下架构的客户端/服务器应用程序:

Image

税费计算服务器将采用文档 |https://tahe.developpez.com/tutoriels-cours/php7| 中开发的第 14 版

18.2. 应用程序的视图

[vuejs-10] 应用程序的视图与文档 |https://tahe.developpez.com/tutoriels-cours/php7| 中第 13 版税费计算服务器的视图一致,当该服务器以 HTML 模式运行时。 但在本应用中,这些视图将由 JavaScript 客户端生成,而非由 PHP 服务器生成。

第一个视图是身份验证视图:

Image

第二个视图是税额计算视图:

Image

第三个视图是显示用户所做模拟列表的视图:

Image

上图显示可以删除第1个模拟。随后将显示以下界面:

Image

如果现在删除最后一个模拟,将显示如下新视图:

Image

18.3. 项目元素 [vuejs-20]

项目 [vuejs-20] 的结构如下:

Image

该项目的组成部分如下:

  • [assets/logo.jpg]:项目徽标;
  • [couches]:应用程序的 [métier] [dao] 层;
  • [plugins]:应用程序的插件;
  • [views]:应用程序的视图;
  • [config.js]:配置应用程序;
  • [router.js]:定义应用程序的路由;
  • [store.js][Vuex] 的存储;
  • [main.js]:应用程序的主脚本;

18.3.1. [métier] [dao]

18.3.1.1. [dao]

[dao] 层由 |vuejs-10| 段落中的 [Dao] 类实现

18.3.1.2. [métier]

[métier] 层由文档 |https://tahe.developpez.com/tutoriels-cours/php7| 中的 [Métier] 类实现。 其中添加了以下 [setTaxAdminData] 方法:


// 构造函数
  constructor(taxAdmindata) {
    // this.taxAdminData:税务管理数据
    this.taxAdminData = taxAdmindata;
  }

  // 设置器
  setTaxAdminData(taxAdmindata) {
    // this.taxAdminData:税务部门数据
    this.taxAdminData = taxAdmindata;
}

方法 [setTaxAdminData] 与构造函数的功能相同。它的存在使得以下操作序列成为可能:

  1. 当需要实例化类但尚未拥有数据 [taxAdminData] 时,使用指令 [métier=new Métier()] 来实例化类 [Métier]
  2. 随后通过操作 [métier.setTaxAdminData(taxAdmindata)] 填充其属性 [taxAdminData]

18.3.2. 配置文件 [config]

文件 [config.js] 内容如下:


// QZXW2HTML库的使用 BW2F4aW9zXQZQX
const axios = require('axios');
// 请求超时 HTTP
axios.defaults.timeout = 2000;
// 税款计算服务器的 URL 数据库
// [https] 架构在 Firefox 中存在问题,因为税款计算服务器
// 发送了自签名证书。Chrome和Edge正常。Safari未测试。
axios.defaults.baseURL = 'https://localhost/php7/scripts-web/impots/version-14';
// 我们将使用Cookie
axios.defaults.withCredentials = true;

// 导出配置
export default {
  axios: axios
}

此配置属于库文件 [axios][dao] 层使用该库来执行其 HTTP 请求。请注意第 8 行,服务器运行在安全端口 [https] 上。

18.3.3. 插件

[pluginDao, pluginMétier, pluginConfig] 插件旨在为函数/类 [Vue] 创建三个新属性:

  • [$dao]:其值为 [Dao] 类的实例;
  • [$métier]:其值为 [Métier] 类的实例;
  • [$config]:其值为配置文件 [config] 导出的对象;

[pluginDao]


export default {
  install(Vue, dao) {
    // 向 Vue 类添加属性 [$dao]
    Object.defineProperty(Vue.prototype, '$dao', {
      // 当引用 Vue.$dao 时,返回第二个参数 [dao]
      get: () => dao,
    })
  }
}

[pluginMétier]

export default {
  install(Vue, métier) {
    // 向 Vue 类添加属性 [$métier]
    Object.defineProperty(Vue.prototype, '$métier', {
      // 当引用 Vue.$métier 时,将返回第二个参数 [métier]
      get: () => métier,
    })
  }
}

[pluginConfig]


export default {
  install(Vue, config) {
    // 为视图类添加属性 [$config]
    Object.defineProperty(Vue.prototype, '$config', {
      // 当引用 Vue.$config 时,将返回第二个参数 [config]
      get: () => config,
    })
  }
}

18.3.4. [Vuex] 存储

[Vuex] 控件由以下文件 [store] 实现:


// Vuex 插件
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex);

// Vuex 存储
const store = new Vuex.Store({
  state: {
    // 模拟表
    simulations: [],
    // 最后一次模拟的编号
    idSimulation: 0
  },
  mutations: {
    // 删除索引号行
    deleteSimulation(state, index) {
      // eslint-disable-next-line no-console
      console.log("mutation deleteSimulation");
      // 删除第 [index] 行
      state.simulations.splice(index, 1);
      // eslint-disable-next-line no-console
      console.log("store simulations", state.simulations);
    },
    // 添加了一个模拟
    addSimulation(state, simulation) {
      // eslint-disable-next-line no-console
      console.log("mutation addSimulation");
      // 模拟编号
      state.idSimulation++;
      simulation.id = state.idSimulation;
      // 将模拟添加到模拟列表中
      state.simulations.push(simulation);
    },
    // 清理状态
    clear(state) {
      state.simulations = [];
      state.idSimulation = 1;
    }
  }
});
// 导出对象 [store]
export default store;

注释

  • 第 2-4 行:将插件 [Vuex] 集成到框架 [Vue] 中;
  • 第 8-13 行:我们将以下元素放入 [Vuex] 的存储中:
    • [simulations]:用户执行的模拟列表;
    • [idSimulation]:用户最近一次模拟的编号;

需要提醒的是,该存储将在视图之间共享,且其内容具有响应性:当内容发生更改时,使用该存储的视图会自动更新。在我们的应用程序中,仅 [simulations] 元素需要具备响应性,而 [idSimulation] 元素则不需要。 出于方便,我们将其保留在数据存储中;

  • 第 14-40 行:对第 8-13 行中的 [state] 对象允许的修改操作。需注意,这些操作始终将第 8-13 行中的 [state] 对象作为第一个参数接收;
    • 第 16 行:变体 [deleteSimulation] 用于删除编号为 [index] 的模拟;
    • 第25行:通过[addSimulation]操作,可在模拟表中添加一个新的模拟;
    • 第35行:变更[clear]可重置第8-13行中的对象[state]

18.3.5. 路由文件 [router]

路由文件如下:


// 导入
import Vue from 'vue'
import VueRouter from 'vue-router'
// 视图
import Authentification from './views/Authentification'
import CalculImpot from './views/CalculImpot'
import ListeSimulations from './views/ListeSimulations'

// 路由插件
Vue.use(VueRouter)

// 应用程序路由
const routes = [
  // 身份验证
  {
    path: '/', name: 'authentification', component: Authentification
  },
  // 税款计算
  {
    path: '/calcul-impot', name: 'calculImpot', component: CalculImpot
  },
  // 模拟列表
  {
    path: '/liste-des-simulations', name: 'listeSimulations', component: ListeSimulations
  },
  // 结束会话
  {
    path: '/fin-session', name: 'finSession', component: Authentification
  }
]

// 路由器
const router = new VueRouter({
  // 路由
  routes,
  // 浏览器中路由的显示模式
  mode: 'history',
})

// 路由器导出
export default router

注释

  • 第 16 行:应用程序启动时,显示的是视图 [Authentification],因为其父视图 URL 是根视图 [/]
  • 第 20 行:当调用 URL 时,将显示视图 [CalculImpot]
  • 第 24 行:当请求 URL 或 [/liste-des-simualtions] 时,将显示视图 [ListeSimulations]
  • 第 28 行:当请求 URL 或 [/fin-session] 时,将显示视图 [Authentification]
  • 第33-38行:创建了一个[router]对象,其中包含这些路由(第35行)以及[history]管理模式(第37行);
  • 第 41 行:导出该路由器;

18.3.6. 主脚本 [main.js]

脚本 [main.js] 如下:


// 导入
import Vue from 'vue'

// 主视图
import Main from './views/Main.vue'

// 插件 [bootstrap-vue]
import BootstrapVue from 'bootstrap-vue'
Vue.use(BootstrapVue);

// CSS 引导程序
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'

// 路由器
import router from './router'

// 插件 [config]
import config from './config';
import pluginConfig from './plugins/pluginConfig'
Vue.use(pluginConfig, config)

// [dao] 层实例化
import Dao from './couches/Dao';
const dao = new Dao(config.axios);

// 插件 [dao]
import pluginDao from './plugins/pluginDao'
Vue.use(pluginDao, dao)

// [métier] 层实例化
import Métier from './couches/Métier';
const métier = new Métier();

// 插件 [métier]
import pluginMétier from './plugins/pluginMétier'
Vue.use(pluginMétier, métier)

// Vuex 存储
import store from './store'

// 启动 UI
new Vue({
  el: '#应用,
  // 路由器
  router: router,
  // Vuex 存储
  store: store,
  // 主视图
  render: h => h(Main),
})

请注意以下几点:

  • 第18-21行,由脚本[./config]导出的对象将出现在属性[Vue.$config]中,因此可在应用程序的所有视图中使用。 此处无需如此,因为对象 [config] 仅由脚本 [main] 使用(第 25 行)。不过,配置通常需要供多个视图使用。因此,此处仍遵循将其置于视图属性中的原则;
  • 第 24-25 行:实例化 [dao] 层。 第 24 行导入类 [Dao],并在第 25 行对其进行实例化。其构造函数仅接受一个参数:配置属性对象 [axios]
  • 第27-29行:[dao]层在所有视图的[$dao]属性中可用;
  • 第31-37行:对图层[métier]重复相同的操作序列。 类[Métier]的构造函数参数为[taxAdminData],该参数代表税务管理数据。我们目前尚未获取该数据。因此,第33行的对象[métier]需稍后补充;
  • 第40行:导入存储对象[Vuex]
  • 第43-51行:实例化主视图[Main](第5行和第50行),并向其传递两个参数:
    • 第46行:定义第16行中的路由器[router]
    • 第 48 行:定义了在第 40 行定义的 [Vuex] [store] 存储;
    • 在这两种情况下,属性名位于左侧,其值位于右侧。[router, store]的属性名[vue-router]和[vuex]框架确定。关联的值可以是任意值;

18.4. 应用程序的视图

18.4.1. 主视图 [Main]

主视图 [Main] 的代码如下:


<!-- 视图定义 -->
<template>
  <div class="container">
    <b-card>
      <!-- 巨型显示屏 -->
      <b-jumbotron>
        <b-row>
          <b-col cols="4">
            <img src="../assets/logo.jpg" alt="Cerisier en fleurs" />
          </b-col>
          <b-col cols="8">
            <h1>Calculez votre impôt</h1>
          </b-col>
        </b-row>
      </b-jumbotron>
      <!-- HTTP 请求错误 -->
      <b-alert
        show
        variant="danger"
        v-if="showError"
      >L'erreur suivante s'est produite : {{error.message}}</b-alert>
      <!-- 当前视图 -->
      <router-view v-if="showView" @loading="mShowLoading" @error="mShowError" />
      <!-- 加载中 -->
      <b-alert show v-if="showLoading" variant="light">
        <strong>Requête au serveur de calcul d'impôt en cours...</strong>
        <div class="spinner-border ml-auto" role="status" aria-hidden="true"></div>
      </b-alert>
    </b-card>
  </div>
</template>

<script>
export default {
  // 名称
  name: "app",
  // 内部状态
  data() {
    return {
      // 检查待处理警报
      showLoading: false,
      // 控制错误警报
      showError: false,
      // 控制当前路由视图的显示
      showView: true,
      // 一条错误消息
      error: ""
    };
  },
  // 事件处理程序
  methods: {
    // 异步请求错误
    mShowError(error) {
      // eslint-disable-next-line
      console.log("Main evt error");
      // 显示错误消息
      this.error = error;
      this.showError = true;
      // 隐藏路由视图
      this.showView = false;
      // 隐藏加载提示
      this.showLoading = false;
    },
    // 是否显示加载图标
    mShowLoading(value) {
      // 禁用 eslint 下一行检查
      console.log("Main evt showLoading");
      // 是否显示等待提示
      this.showLoading = value;
    }
  }
};
</script>

注释

  • 视图 [Main] 负责对路由并显示的视图进行排版(第 23 行):

Image

  • 第 5-15 行显示区域 1;
  • 第23行显示路由视图[2]
  • 第16-19行:仅在与税款计算服务器通信出现错误时显示警报;
  • 第25-28行:每次向服务器发出HTTP请求时显示的等待提示;
  • 所有视图都将采用此布局显示,因为每个路由视图均通过第20-24行进行显示。视图[Main]用于提取不同视图间可共享的代码;
  • 第23行:每个路由视图可触发三个事件:
    • [loading]:已发起 HTTP 请求。需显示等待响应的消息;
    • [error]:请求 HTTP 因错误而终止。需显示错误消息并隐藏路由视图;
  • 第 38-49 行:视图状态:
    • 第 41 行:[showLoading] 控制显示请求 HTTP(第 25 行)结束时的等待消息;
    • 第 43 行:[showError] 控制显示请求 HTTP(第 17-21 行)的错误消息;
    • 第 45 行:[showView] 控制路由视图(第 23 行)的显示;
  • 第 53-63 行:方法 [mShowError] 处理由路由视图(第 23 行)发出的事件 [error]
  • 第 65-70 行:方法 [mShowLoading] 处理路由视图(第 23 行)发出的事件 [loading]
  • 第 23 行:需注意事件 [error] [loading]。只有当路由视图 [showView=true] 被显示时,这些事件才会被拦截。这就是为什么路由视图最初会被显示(第 45 行)。 只有在发生错误时,该视图才会被隐藏(第60行)。为避免此问题,本可以使用指令 [v-show] 代替 [v-if]。这两条指令的区别如下:
    • [v-if=’false’] 通过将其从全局代码 HTML 中移除,从而隐藏受控块。此时,路由视图的事件将无法被拦截;
    • [v-show=’false’] 通过修改其 CSS 来隐藏受控块,但该块的代码仍保留在全局 HTML 中,因此仍可拦截路由视图的事件;

18.4.2. 布局视图 [Layout]

视图 [Layout] 的代码如下:


<!-- 定义 HTML 路由视图的布局 -->
<template>
  <!-- 行 -->
  <div>
    <b-row>
      <!-- 左侧三栏区域 -->
      <b-col cols="3" v-if="left">
        <slot name="left" />
      </b-col>
      <!-- 右侧九列区域 -->
      <b-col cols="9" v-if="right">
        <slot name="right" />
      </b-col>
    </b-row>
  </div>
</template>

<script>
  export default {
    // 视图参数
    props: {
      // 控制左侧栏
      left: {
        type: Boolean
      },
      // 控制右侧列
      right: {
        type: Boolean
      }
    }
  };
</script>

注释

  • 视图 [Layout] 可将路由视图划分为两个区域:
    • 左侧为 3 列 Bootstrap 区域(第 7-9 行)。该区域将用于显示导航菜单(如有);
    • 右侧为 9 列区域(第 11-13 行)。该区域将显示路由视图传递的信息;

18.4.3. 视图 [Authentification]

身份验证视图如下:

Image

该视图由 [Layout] 生成,通过删除左侧列仅显示右侧列。

其代码如下:


<!-- 视图定义 HTML -->
<template>
  <Layout :left="false" :right="true">
    <template slot="right">
      <!-- 表单 HTML - 使用操作 [authentifier-utilisateur] 提交其值 -->
      <b-form @submit.prevent="login">
        <!-- 标题 -->
        <b-alert show variant="primary">
          <h4>Bienvenue. Veuillez vous authentifier pour vous connecter</h4>
        </b-alert>
        <!-- 第一行 -->
        <b-form-group label="Nom d'utilisateur" label-for="user" label-cols="3">
          <!-- 用户输入区 -->
          <b-col cols="6">
            <b-form-input type="text" id="user" placeholder="Nom d'utilisateur" v-model="user" />
          </b-col>
        </b-form-group>
        <!-- 第二行 -->
        <b-form-group label="Mot de passe" label-for="password" label-cols="3">
          <!-- 密码输入框 -->
          <b-col cols="6">
            <b-input type="password" id="password" placeholder="Mot de passe" v-model="password" />
          </b-col>
        </b-form-group>
        <!-- 第三行 -->
        <b-alert
          show
          variant="danger"
          v-if="showError"
          class="mt-3"
        >L'erreur suivante s'est produite : {{message}}</b-alert>
        <!-- 第三行上的 [submit] 类型按钮 -->
        <b-row>
          <b-col cols="2">
            <b-button variant="primary" type="submit" :disabled="!valid">Valider</b-button>
          </b-col>
        </b-row>
      </b-form>
    </template>
  </Layout>
</template>

<!-- 视图动态 -->
<script>
import Layout from "./Layout";
export default {
  // 组件状态
  data() {
    return {
      // 用户
      user: "",
      // 其密码
      password: "",
      // 控制错误消息的显示
      showError: false,
      // 错误消息
      message: "",
      // 会话已启动
      sessionStarted: false
    };
  },

  // 使用的组件
  components: {
    Layout
  },

  // 计算属性
  computed: {
    // 有效输入
    valid() {
      return this.user && this.password && this.sessionStarted;
    }
  },

  // 事件处理程序
  methods: {
    // ----------- 身份验证
    async login() {
      try {
        // 开始等待
        this.$emit("loading", true);
        // 服务器端阻塞式身份验证
        const response = await this.$dao.authentifierUtilisateur(
          this.user,
          this.password
        );
        // 加载结束
        this.$emit("loading", false);
        // 响应分析
        if (response.état != 200) {
          // 显示错误
          this.message = response.réponse;
          this.showError = true;
          return;
        }
        // 无错误
        this.showError = false;
        // --------- 现在向税务部门请求数据
        // 开始等待
        this.$emit("loading", true);
        // 向服务器发送阻塞请求
        const response2 = await this.$dao.getAdminData();
        // 加载完成
        this.$emit("loading", false);
        // 分析响应
        if (response2.état != 1000) {
          // 显示错误
          this.message = response2.réponse;
          this.showError = true;
          return;
        }
        // 无错误
        this.showError = false;
        // 将接收到的数据存储在 [métier] 层中
        this.$métier.setTaxAdminData(response2.réponse);
        // 跳转至税款计算视图
        this.$router.push({ name: "calculImpot" });
      } catch (error) {
        // 将错误上报至主组件
        this.$emit("error", error);
      }
    }
  },
  // 生命周期:组件刚被创建
  created() {
    // eslint-disable-next-line
    console.log("authentification", "created");
    // 正在与服务器建立 jSON 会话
    // 开始等待
    this.$emit("loading", true);
    // 正在与服务器初始化会话 - 异步请求
    // 使用 QZXW2HTML 层方法返回的承诺
    this.$dao
      // 初始化会话 jSON
      .initSession()
      // 已获取响应
      .then(response => {
        // 等待结束
        this.$emit("loading", false);
        // 分析响应
        if (response.état != 700) {
          // 显示错误
          this.message = response.réponse;
          this.showError = true;
          return;
        }
        // 会话已启动
        this.sessionStarted = true;
      })
      // 若发生错误
      .catch(error => {
        // 将错误上报至视图 [Main]
        this.$emit("error", error);
      });
  }
};
</script>

注释

  • 第 3 行:视图 [Authentification] 仅使用 [Layout] 的右侧列(第 3 行和第 4 行);
  • 第6-38行:生成上图区域1的Bootstrap表单;
  • 第6行:当用户点击第35行的[submit]类型按钮时,将触发[@submit]事件。 修饰符 [prevent] 要求在 [submit] 事件发生时不重新加载页面。也可以这样写:
    • 一个不处理 [submit] 事件的 <b-form> 标签;
    • 一个包含 [@click=’login’] 事件但未添加 [type=’submit’] 属性的 <b-button> 标签;

这同样可行。所选方案的优势在于,提交操作不仅可以通过点击按钮([Valider])实现,还可以通过输入框中的回车键([Entrée])触发。 因此,出于用户便利的考虑,此处采用了 [<b-form @submit.prevent="login">] 方案;

  • 第33-37行:当服务器拒绝用户输入的凭据时弹出的提示:

Image

  • 第35行:按钮[Valider]并非始终处于活动状态。其状态取决于第71-73行中计算属性[valid]。当满足以下条件时,属性[valid]为真:
    • 表单的 [user, password] 字段中有内容;
    • jSON 会话已启动。初始时,该会话尚未启动(第 59 行),因此 [Valider] 按钮处于非活动状态。
  • 第 49-60 行:视图状态;
    • [user] 表示用户在表单字段 [user](第 12-17 行)中的输入。 第15行的指令[v-model]在用户输入与视图的属性[user]之间建立了双向关联;
    • [password] 表示表单中字段 [password](第 19-24 行)中的用户输入。 第22行的指令[v-model]在用户输入与视图的属性[password]之间建立了双向关联;
    • [showError] 控制(第 29 行)第 26-31 行警报的显示;
    • [message] 是应显示在第 26-31 行警报中的错误消息(第 31 行);
    • [sessionStarted] 指示与服务器的 jSON 会话是否已启动。初始时,该属性的值为 [false](第 59 行)。 与服务器的会话 jSON 在视图生命周期的事件 [created] 中初始化(第 126-156 行)。 如果服务器响应成功,则 [sessionStarted] 属性将更新为 [true](第 149 行);
  • 第 126-156 行:当视图 [Authentification] 创建后(未必已显示),将执行函数 [created]。随后在后台与服务器初始化会话 jSON。 我们知道这是与税款计算服务器交互的首个操作。为此,我们使用应用程序的 [dao] 层(第 134 行)。 该层的所有方法均为异步的。此处使用由方法 [$dao.initSession] 返回的 Promise,该方法用于与服务器初始化会话 jSON。
  • 第138-150行:当服务器返回无错误响应时执行的代码;
  • 第142行:检查响应的[état]属性。若操作成功,该属性应为[700]。 否则,则发生错误,其原因在属性 [response.réponse](第 144 行)中给出。此时将显示视图的错误消息(第 145 行);
  • 第 149 行:注意会话 jSON 已启动;
  • 第 152-155 行:错误发生时执行的代码。该错误被上报至父视图 [Main],该视图
    • 将显示该错误;
    • 将隐藏等待消息;
    • 隐藏路由视图(即视图 [Autentification]);
  • 第 79-124 行:方法 [login] 处理对按钮 [Valider] 的点击;
  • 第 79 行:该方法前缀了关键字 [async],以便在第 84 行和第 103 行使用关键字 [await]
  • 第 84-87 行:对方法 [$dao.authentifierUtilisateur(user, password)] 进行了阻塞调用。本可以像在函数 [created] 中那样使用 [Promise] 承诺。 我们希望尝试不同的风格。阻塞用户不会造成风险,因为我们为所有 HTTP 请求都设置了 2 秒的 [timeout]。 用户无需等待太久。此外,在服务器返回响应之前,用户无法进行任何操作,因为此时 [Valider] 按钮处于不可用状态;
  • 第91行:税费计算服务器发送的响应jSON均采用[{‘action’:action, ‘état’:val, ‘réponse’:réponse}]的结构。 若 [état==200] 成立,则认证成功。否则,将显示错误信息(第 93-94 行);
  • 第 98 行:隐藏前一操作可能产生的错误信息;
  • 第99-116行:现在向服务器请求税务管理数据,以便计算税款。在[this.$métier]中,我们有一个[Métier]类的实例,由于目前尚未获取这些数据,因此该实例暂时无法执行任何操作;
  • 第 103 行:通过阻塞操作向服务器请求税务部门的数据;
  • 第107-112行:分析服务器的响应。其状态值必须等于1000,否则即表示发生错误。若发生错误,则显示错误信息(第109-110行);
  • 第113-118行:若操作成功,则:
    • 隐藏错误信息(第114行);
    • 将税务部门的数据传输至 [métier] 层(第 116 行);
    • 显示视图 [CalculImpot],第 118 行。需注意,[this.$router] 表示应用程序的路由器。方法 [push] 用于确定下一个被路由的视图。 此处通过其属性 [name] 来指定该视图。同样也可以通过其属性 [path] 来指定。这些信息位于路由文件中:

// 计算税款
  {
    path: '/calcul-impot', name: 'calculImpot', component: CalculImpot
  },
  • 第119-122行:当两个HTTP请求中的任一个失败(服务器不存在、超时等)时,[catch]会被触发。 此时将错误上报给父视图 [Main],该视图将显示该错误,同时隐藏等待提示信息及视图 [Authentification]

18.4.4. 视图 [CalculImpot]

视图 [CalculImpot] 如下所示:

Image

  • [1]:导航菜单占据路由视图的左侧栏;
  • [2]:税款计算表占据路由视图的右侧栏;

视图 [CalculImpot] 的代码如下:


<!-- 视图定义 HTML -->
<template>
  <div>
    <Layout :left="true" :right="true">
      <!-- 右侧税款计算表单 -->
      <FormCalculImpot slot="right" @resultatObtenu="handleResultatObtenu" />
      <!-- 左侧导航菜单 -->
      <Menu slot="left" :options="options" />
    </Layout>
    <!-- 表单下方的税款计算结果显示区 -->
    <b-row v-if="résultatObtenu" class="mt-3">
      <!-- 三列空白区域 -->
      <b-col cols="3" />
      <!-- 九列区域 -->
      <b-col cols="9">
        <b-alert show variant="success">
          <span v-html="résultat"></span>
        </b-alert>
      </b-col>
    </b-row>
  </div>
</template>

<script>
// 导入
import FormCalculImpot from "./FormCalculImpot";
import Menu from "./Menu";
import Layout from "./Layout";

export default {
  // 内部报表
  data() {
    return {
      // 菜单选项
      options: [
        {
          text: "Liste des simulations",
          path: "/liste-des-simulations"
        },
        {
          text: "Fin de session",
          path: "/fin-session"
        }
      ],
      // 税额计算结果
      résultat: "",
      résultatObtenu: false
    };
  },
  // 使用的组件
  components: {
    Layout,
    FormCalculImpot,
    Menu
  },
  // 事件管理方法
  methods: {
    // 税额计算结果
    handleResultatObtenu(résultat) {
      // 将结果构建为字符串 HTML
      const impôt = "Montant de l'impôt : " + résultat.impôt + " euro(s)";
      const décôte = "Décôte : " + résultat.décôte + " euro(s)";
      const réduction = "Réduction : " + résultat.réduction + " euro(s)";
      const surcôte = "Surcôte : " + résultat.surcôte + " euro(s)";
      const taux = "Taux d'imposition : " + résultat.taux;
      this.résultat =
        impôt +
        "<br/>" +
        décôte +
        "<br/>" +
        réduction +
        "<br/>" +
        surcôte +
        "<br/>" +
        taux;
      // 显示结果
      this.résultatObtenu = true;
      // ---- 更新存储 [Vuex]
      // + 模拟
      this.$store.commit("addSimulation", résultat);
    }
  }
};
</script>

注释

  • 第 4 行:[Layout] 的两列在此处显示;
  • 第6行:税额计算表占据右侧一列。当计算出税额结果时,它会触发事件[resultatObtenu]。需注意,事件名称及其管理方法的名称中不得包含带重音的字符;
  • 第 8 行:导航菜单位于左侧栏;
  • 第11-20行:税额计算结果显示在表单下方:

Image

  • 第 11 行:只有当属性 [résultatObtenu](第 47 行)的值为 [true] 时,才会显示结果;
  • 第34-48行:视图状态:
    • [options]:导航菜单的选项列表。该数组作为参数传递给第8行的[Menu]组件
    • [résultat]:税额计算结果。该结果是一个字符串 HTML。因此,我们在第 17 行使用了指令 [v-html] 来显示它;
    • [résultatObtenu]:控制结果显示的布尔值,第11行;
  • 第 59-81 行:方法 [handleResultatObtenu] 显示由子视图 [FormCalculImpot](第 6 行)传入的税额计算结果。该结果是一个具有属性 [impot, décôte, réduction, surcôte, taux, marié, enfants, salaire] 的对象;
  • 第 61-75 行:将对象 [impot, décôte, réduction, surcôte, taux] 写入文本 HTML,该文本由模板的第 17 行显示;
  • 第 77 行:显示该结果;
  • 第 80 行:调用 Vuex 存储中的 [addSimulation] 变体,该变体将 [résultat] 添加到存储中已有的模拟中;

18.4.5. 导航菜单 [Menu]

导航菜单显示在路由视图的左侧栏中:

Image

视图 [Menu] 的代码如下:


<!-- 视图定义 HTML -->
<template>
  <!-- Bootstrap 垂直菜单 -->
  <b-nav vertical>
    <!-- 菜单选项 -->
    <b-nav-item
      v-for="(option,index) of options"
      :key="index"
      :to="option.path"
      exact
      exact-active-class="active"
    >{{option.text}}</b-nav-item>
  </b-nav>
</template>

<script>
export default {
  // 视图参数
  props: {
    options: {
      type: Array
    }
  }
};
</script>

注释

  • 菜单选项由参数 [options] 提供(第 7、20-22 行);
  • 数组 [options] 的每个元素都具有属性 [text](第 12 行),该属性为链接文本;以及属性 [path](第 9 行),该属性为链接目标视图的路径;

18.4.6. 视图 [FormCalculImpot]

该视图提供税款计算表单:

Image

其代码如下:


  <!-- 视图 HTML 的定义 -->
  <template>
  <!-- 表单 HTML -->
  <b-form @submit.prevent="calculerImpot" class="mb-3">
    <!-- 12 列蓝色背景消息 -->
    <b-alert show variant="primary">
      <h4>Remplissez le formulaire ci-dessous puis validez-le</h4>
    </b-alert>
    <!-- 表单元素 -->
    <!-- 第一行 -->
    <b-form-group label="Etes-vous marié(e) ou pacsé(e) ?" label-cols="4">
      <!-- 5列单选按钮-->
      <b-col cols="5">
        <b-form-radio v-model="marié" value="oui">Oui</b-form-radio>
        <b-form-radio v-model="marié" value="non">Non</b-form-radio>
      </b-col>
    </b-form-group>
    <!-- 第二行 -->
    <b-form-group label="Nombre d'enfants à charge" label-cols="4" label-for="enfants">
      <b-input
        type="text"
        id="enfants"
        placeholder="Indiquez votre nombre d'enfants"
        v-model="enfants"
        :state="enfantsValide"
      />
      <!-- 可能出现的错误信息 -->
      <b-form-invalid-feedback :state="enfantsValide">Vous devez saisir un nombre positif ou nul</b-form-invalid-feedback>
    </b-form-group>
    <!-- 第三行 -->
    <b-form-group
      label="Salaire annuel"
      label-cols="4"
      label-for="salaire"
      description="Arrondissez à l'euro inférieur"
    >
      <b-input
        type="text"
        id="salaire"
        placeholder="Salaire annuel"
        v-model="salaire"
        :state="salaireValide"
      />
      <!-- 可能出现的错误信息 -->
      <b-form-invalid-feedback :state="salaireValide">Vous devez saisir un nombre positif ou nul</b-form-invalid-feedback>
    </b-form-group>
    <!-- 第四行,5列中的按钮 [submit] -->
    <b-col cols="5">
      <b-button type="submit" variant="primary" :disabled="formInvalide">Valider</b-button>
    </b-col>
  </b-form>
</template>

<!-- 脚本 -->
<script>
export default {
  // 内部状态
  data() {
    return {
      // 已婚或未婚
      marié: "non",
      // 子女数量
      enfants: "",
      // 年薪
      salaire: ""
    };
  },
  // 计算出的内部状态
  computed: {
    // 表单验证
    formInvalide() {
      return (
        // 工资无效
        !this.salaireValide ||
        // 或子女信息无效
        !this.enfantsValide ||
        // 或未获取税务数据
        !this.$métier.taxAdminData
      );
    },
    // 工资验证
    salaireValide() {
      // 必须为数字且大于等于0
      return Boolean(this.salaire.match(/^\s*\d+\s*$/));
    },
    // 子女信息验证
    enfantsValide() {
      // 必须为数字且大于等于0
      return Boolean(this.enfants.match(/^\s*\d+\s*$/));
    }
  },
  // 事件管理器
  methods: {
    calculerImpot() {
      // 使用 [métier] 层计算税款
      const résultat = this.$métier.calculerImpot(
        this.marié,
        this.enfants,
        this.salaire
      );
      // eslint-disable-next-line
      console.log("résultat=", résultat);
      // 完善结果
      résultat.marié = this.marié;
      résultat.enfants = this.enfants;
      résultat.salaire = this.salaire;
      // 触发事件 [resultatObtenu]
      this.$emit("resultatObtenu", résultat);
    }
  }
};
</script>

注释

  • 第 4-51 行:Bootstrap 表单;
  • 第11-17行:一组单选按钮及其标签;
  • 第 14-15 行:<b-form-radio> 标签用于显示单选按钮:
    • 第 14 行: 指令 [v-model] 确保点击该按钮时,第 61 行的 [marié] 属性将获得值 [oui](即 [value="oui"] 属性);
    • 第15行: 指令 [v-model] 确保点击按钮时,第 61 行中的属性 [marié] 将被赋值为 [non](属性 [value="non"]);
  • 第19-29行:输入子女数量的部分:
    • 第24行:子女数量的输入与第63行的属性[enfants]相关联;
    • 第25行:通过第87-89行的计算属性[enfantsValide]验证输入的有效性;
    • 第28行:若输入无效,则显示错误信息;
  • 第31-45行:年度工资的输入部分:
    • 第 35 行:在输入框正下方显示帮助信息;
    • 第 41 行:薪资输入与第 65 行的属性 [salaire] 相关联;
    • 第 42 行:通过第 82-85 行的计算属性 [salaireValide] 验证输入的有效性;
    • 第 45 行:确保在输入无效时显示错误消息;
  • 第48-50行:一个类型为[submit]的按钮。当点击该按钮或使用[Entrée]键确认输入时,将执行方法[calculerImpot](第94行);
    • 第 49 行:按钮的启用/禁用状态由第 71-80 行中的计算属性 [formInvalide] 控制;
  • 第 71-80 行:如果满足以下条件,则表单有效:
    • 子女数量有效;
    • 工资有效;
    • 应用程序已从服务器获取税务管理数据,以便计算税款。需注意,该数据存储在属性 [$métier.taxAdminData] 中。 在获取该数据之前即可显示视图 [FormCalculImpot],因为该数据是在视图显示的同时以异步方式请求的。 此处确保在数据获取完成前,用户无法点击按钮 [Valider]
  • 第 94-109 行:税额计算方法:
    • 第96-100行:由[métier]层执行此计算。这是一项同步计算。一旦获取了[taxAdminData]数据,客户端[Vue]便无需再与服务器通信。 所有操作均在本地完成。最终得到一个具有 [impôt, décôte, surcôte, réduction, taux] 属性的 [résultat] 对象;
    • 第 104-106 行:向结果中添加属性 [marié, enfants, salaire]
    • 第 108 行:通过事件 [resultatObtenu],将结果传递给父视图 [CalculImpot]。该视图负责显示结果;

18.4.7. 视图 [ListeSimulations]

视图 [ListeSimulations] 显示用户执行的模拟列表:

Image

该视图的代码如下:


<!-- 视图定义 HTML -->
<template>
  <div>
    <!-- 版面设计 -->
    <Layout :left="true" :right="true">
      <!-- 右侧栏中的模拟 -->
      <template slot="right">
        <template v-if="simulations.length==0">
          <!-- 无模拟 -->
          <b-alert show variant="primary">
            <h4>Votre liste de simulations est vide</h4>
          </b-alert>
        </template>
        <template v-if="simulations.length!=0">
          <!-- 有模拟 -->
          <b-alert show variant="primary">
            <h4>Liste de vos simulations</h4>
          </b-alert>
          <!-- 模拟图表 -->
          <b-table striped hover responsive :items="simulations" :fields="fields">
            <template v-slot:cell(action)="data">
              <b-button variant="link" @click="supprimerSimulation(data.index)">Supprimer</b-button>
            </template>
          </b-table>
        </template>
      </template>
      <!-- 左侧栏导航菜单 -->
      <Menu slot="left" :options="options" />
    </Layout>
  </div>
</template>

<script>
  // 导入
  import Layout from "./Layout";
  import Menu from "./Menu";
  export default {
    // 组件
    components: {
      Layout,
      Menu
    },
    // 内部状态
    data() {
      return {
        // 导航菜单选项
        options: [
          {
            text: "Calcul de l'impôt",
            path: "/calcul-impot"
          },
          {
            text: "Fin de session",
            path: "/fin-session"
          }
        ],
        // 表参数 HTML
        fields: [
          { label: "#", key: "id" },
          { label: "Marié", key: "marié" },
          { label: "Nombre d'enfants", key: "enfants" },
          { label: "Salaire", key: "salaire" },
          { label: "Impôt", key: "impôt" },
          { label: "Décôte", key: "décôte" },
          { label: "Réduction", key: "réduction" },
          { label: "Surcôte", key: "surcôte" },
          { label: "", key: "action" }
        ]
      };
    },
    // 计算出的内部状态
    computed: {
      // 从 Vuex 存储中提取的模拟列表
      simulations() {
        return this.$store.state.simulations;
      }
    },
    // 方法
    methods: {
      supprimerSimulation(index) {
        // eslint-disable-next-line
        console.log("supprimerSimulation", index);
        // 删除编号为 [index] 的模拟
        this.$store.commit("deleteSimulation", index);
      }
    }
  };
</script>

注释

  • 第 5 行:该视图占据了路由视图布局 [Layout] 的两列;
  • 第7-26行:模拟结果显示在右侧栏中;
  • 第 28 行:导航菜单位于左侧列;
  • 第8、14、20、75行:模拟数据来自存储库[Vuex] [$this.store]
  • 第 8-13 行:当模拟列表为空时显示警报;
  • 第14-25行:当模拟列表不为空时显示的HTML表格;
  • 第 20-24 行:通过 <b-table> 标签生成表格 HTML;
    • 第 20 行:模拟表由第 74-76 行的计算属性 [simulations] 提供;
    • 第 20 行:表 HTML 的配置由第 58-69 行中的计算属性 [fields] 完成。 第 67 行,主键列 [action] 是表 HTML 的最后一列;
    • 第 21-23 行:表 HTML 的最后一列模板;
    • 第22行:在此处放置一个链接类型的按钮。 点击该按钮时,将调用方法 [supprimerSimulation(data.index)],其中 [data] 代表当前行(第 21 行)。[data.index] 代表该行在显示行列表中的序号;
  • 第28行:生成导航菜单。其选项由第47-56行的[options]属性提供;
  • 第 80-85 行:响应页面 HTML 上链接 [Supprimer] 被点击的方法;
    • 第 84 行:调用存储 [Vuex] 中的变体 [deleteSimulation](参见 |vuejs-15| 段落);

18.5. 项目执行

Image

还需启动服务器 [Laragon](参见文档 |https://tahe.developpez.com/tutoriels-cours/php7|),以便使税费计算服务器上线。

18.6. 在本地服务器上部署应用程序

目前,我们的客户端 [Vue] 已部署在测试服务器 URL 和 [http://localhost:8080] 上。 我们将把它部署到 [Laragon] 服务器上,该服务器位于 URL 和 [http://localhost:80] 之间。要完成这一操作,需要执行以下几个步骤。

步骤 1

首先,我们将确保客户端 [Vue] 已部署在测试服务器 URL 和 [http://localhost:8080/client-vuejs-impot/] 上。

我们在当前项目 [VSCode] 的根目录下创建一个名为 [vue.config.js] 的文件:

Image

文件 [vue.config.js] [1] 的内容如下:


// vue.config.js
module.exports = {
  // URL 客户服务 [vuejs] 税费计算服务器
  publicPath: '/client-vuejs-impot/'
}

我们还需要修改路由文件 [router.js] [2]


// 导入
import Vue from 'vue'
import VueRouter from 'vue-router'
// 视图
import Authentification from './views/Authentification'
import CalculImpot from './views/CalculImpot'
import ListeSimulations from './views/ListeSimulations'

// 路由插件
Vue.use(VueRouter)

// 应用程序路由
const routes = [
  // 身份验证
  {
    path: '/', name: 'authentification', component: Authentification
  },
  // 税款计算
  {
    path: '/calcul-impot', name: 'calculImpot', component: CalculImpot
  },
  // 模拟列表
  {
    path: '/liste-des-simulations', name: 'listeSimulations', component: ListeSimulations
  },
  // 结束会话
  {
    path: '/fin-session', name: 'finSession', component: Authentification
  }
]

// 路由器
const router = new VueRouter({
  // 路由
  routes,
  // 浏览器中路由的显示模式
  mode: 'history',
  // 应用程序的基础URL
  base: '/client-vuejs-impot/'
})

// 路由器导出
export default router
  • 第 39 行:告知路由器,第 13-30 行定义的路由路径相对于第 39 行定义的路径。例如,第 20 行 [/calcul-impot] 的路径将变为 [/client-vuejs-impot/calcul-impot]

随后可以重新测试项目 [vuejs-20],以验证应用程序路径的变更:

Image

步骤 2

现在我们构建项目 [vuejs-20] 的生产版本:

Image

  • [1-2] 中,我们在文件 [package.json] [1] 中配置任务 [build] [2]
  • [3-5] 中,我们执行该任务。该任务将构建项目 [vuejs-20] 的生产版本;

任务 [build] 的执行发生在 [VSCode] 的终端中:

Image

Image

  • [3-6] 中,警告提示生成的代码过大,需要将其拆分为 [8]。这涉及代码架构的优化,本文不作讨论;
  • [7] 中,提示我们文件 [dist] 包含生成的生产版本:

Image

  • [3] 中,文件 [index.html] 将在请求 URL 或 [https://localhost:80/client-vue-js-impot/] 时被调用;

这里是一个静态网站,可以部署在任何服务器上。我们将把它部署到本地 Laragon 服务器上(参见文档 |https://tahe.developpez.com/tutoriels-cours/php7|)。 将文件夹 [dist] [2] 复制到文件夹 [<laragon>/www] [4] 中,其中 <laragon> 是 Laragon 服务器的安装目录。 我们将该文件夹重命名为 [client-vuejs-impot] [5],因为我们已将生产环境配置为在 URL [/client-vuejs-impot/] 上运行。

步骤 3

我们在刚刚创建的 [client-vuejs-impot] 文件夹中添加以下 [.htaccess] 文件:


<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /client-vuejs-impot/
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /client-vuejs-impot/index.html [L]
</IfModule>

Image

该文件是 Apache 网页服务器的配置文件。 如果我们不添加该文件,而是直接请求 URL 和 [https://localhost/client-vuejs-impot/calcul-impot],而不先经过 URL [https://localhost/client-vuejs-impot/],将会得到 404 错误。 使用该文件后,我们成功获取了[CalculImpot]页面。

完成上述操作后,如果尚未启动,请启动 Laragon 服务器,并请求 URL [https://localhost/client-vuejs-impot/]

Image

欢迎读者测试我们应用程序的生产版本。

我们可以对税款计算服务器进行一项修改:即它系统性地发送给客户端的 CORS 头部信息。这一修改是针对从 [localhost:8080] 域运行的客户端版本而必需的。 如今客户端和服务器均在 [localhost:80] 域中运行,CORS 头部信息已无用武之地。

我们修改服务器第 14 版的 [config.json] 文件:

Image

  • [4],并指定从现在起拒绝 CORS 的请求;

保存此修改后,再次请求 URL 和 [https://localhost/client-vuejs-impot/]。系统应能继续正常运行。

18.7. 手动 URL 的管理

用户可能不愿通过导航菜单中的链接进行操作,而是希望在浏览器的地址栏中手动输入应用程序的 URL 链接。 例如,让我们尝试直接访问 URL [https://client-vuejs-impot/calcul-impot],而不经过身份验证步骤。黑客肯定会尝试这样做。我们会看到以下页面:

Image

确实显示了税款计算界面。现在尝试填写输入框并提交:

Image

我们发现,即使输入内容正确,按钮 [1] [Valider] 仍然处于禁用状态。让我们查看视图 [FormCalculImpot] 的代码:


<b-col cols="5">
      <b-button type="submit" variant="primary" :disabled="formInvalide">Valider</b-button>
</b-col>

第 2 行显示,其启用/禁用状态取决于属性 [formInvalide]。该属性是以下计算属性:


formInvalide() {
      return (
        // 工资无效
        !this.salaireValide ||
        // 或子女信息无效
        !this.enfantsValide ||
        //或未获取税务数据
        !this.$métier.taxAdminData
      );
},

第 8 行显示,表单要有效,必须已获取税务数据。然而,这些数据是在用户“跳过”的视图 [Authentification] 验证过程中获取的。因此,用户将无法提交表单。 如果他能够执行该操作,服务器本会返回一条错误信息,提示其未通过身份验证。验证操作必须始终在服务器端进行。浏览器端的验证总是可以被绕过的。只需使用 [Postman] 类型的客户端,它便会向服务器发送原始请求。

现在我们请求 URL 和 [https://localhost/client-vuejs-impot/liste-des-simulations]。结果显示如下:

Image

现在请求 URL 和 [https://localhost/client-vuejs-impot/fin-session]。我们得到以下视图:

Image

现在尝试一个不存在的视图 [https://localhost/client-vuejs-impot/abcd]

Image

我们的应用程序对手动输入的 URL 请求具有相当强的抵抗力。当这些请求被调用时,应用程序的路由器会知晓。因此,在视图最终显示之前,我们可以进行干预。我们将在 [vuejs-21] 项目中探讨这一点。

还有一点需要关注。假设用户按照常规流程进行了一些模拟操作:

Image

现在通过 F5 刷新页面:

Image

我们做了一件不建议做的事:手动输入 URL(输入 F5 其实也是同样的操作)。结果,我们的模拟数据丢失了。

接下来的项目 [vuejs-21] 旨在实现两项改进:

  • 检查用户输入的 URL;
  • 即使用户输入了 URL,仍保留应用程序的缓存。上图可见,我们已丢失了模拟列表;