Skip to content

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

18.1. 架构

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

Image

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

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]:项目徽标;
  • [layers]:应用程序的 [business] [DAO] 层;
  • [plugins]:应用程序的插件;
  • [views]:应用程序的视图;
  • [config.js]:配置应用程序;
  • [router.js]:定义应用程序的路由;
  • [store.js][Vuex] 存储;
  • [main.js]:应用程序的主脚本;

18.3.1. [业务]层和[DAO]

18.3.1.1. [DAO]

[DAO] 层由 |vuejs-10| 章节中的 [Dao] 类实现

18.3.1.2. [business]

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


// constructeur
  constructor(taxAdmindata) {
    // this.taxAdminData : données de l'administration fiscale
    this.taxAdminData = taxAdmindata;
  }
 
  // setter
  setTaxAdminData(taxAdmindata) {
    // this.taxAdminData : données de l'administration fiscale
    this.taxAdminData = taxAdmindata;
}

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

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

18.3.2. 配置文件 [config]

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


// utilisation de la bibliothèque [axios]
const axios = require('axios');
// timeout des requêtes HTTP
axios.defaults.timeout = 2000;
// la base des URL du serveur de calcul de l'impôt
// le schéma [https] pose des problèmes à Firefox parce que le serveur de calcul
// de l'impôt envoie un certificat autosigné. ok avec Chrome et Edge. Safari pas testé.
axios.defaults.baseURL = 'https://localhost/php7/scripts-web/impots/version-14';
// on va utiliser des cookies
axios.defaults.withCredentials = true;
 
// export de la configuration
export default {
  axios: axios
}

此配置用于 [dao] 层进行 HTTP 请求时所使用的 [axios] 库。请注意第 8 行,服务器运行在安全端口 [https] 上。

18.3.3. 插件

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

  • [$dao]:将包含 [Dao] 类的实例;
  • [$métier]:将赋值为 [Métier] 类的实例;
  • [$config]:将被设置为 [config] 配置文件导出的对象;

[pluginDao]


export default {
  install(Vue, dao) {
    // ajoute une propriété [$dao] à la classe Vue
    Object.defineProperty(Vue.prototype, '$dao', {
      // lorsque Vue.$dao est référencé, on rend le 2ième paramètre [dao]
      get: () => dao,
    })
  }
}
 
[pluginMétier]
 
export default {
  install(Vue, métier) {
    // ajoute une propriété [$métier] à la classe Vue
    Object.defineProperty(Vue.prototype, '$métier', {
      // lorsque Vue.$métier est référencé, on rend le 2ième paramètre [métier]
      get: () => métier,
    })
  }
}

[插件配置]


export default {
  install(Vue, config) {
    // ajoute une propriété [$config] à la classe vue
    Object.defineProperty(Vue.prototype, '$config', {
      // lorsque Vue.$config est référencé, on rend le 2ième paramètre [config]
      get: () => config,
    })
  }
}

18.3.4. [Vuex] 存储

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


// plugin Vuex
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex);
 
// store Vuex
const store = new Vuex.Store({
  state: {
    // le tableau des simulations
    simulations: [],
    // le n° de la dernière simulation
    idSimulation: 0
  },
  mutations: {
    // suppression ligne n° index
    deleteSimulation(state, index) {
      // eslint-disable-next-line no-console
      console.log("mutation deleteSimulation");
      // on supprime la ligne n° [index]
      state.simulations.splice(index, 1);
      // eslint-disable-next-line no-console
      console.log("store simulations", state.simulations);
    },
    // ajout d'une simulation
    addSimulation(state, simulation) {
      // eslint-disable-next-line no-console
      console.log("mutation addSimulation");
      // n° de la simulation
      state.idSimulation++;
      simulation.id = state.idSimulation;
      // on ajoute la simulation au tableau des simulations
      state.simulations.push(simulation);
    },
    // nettoyage state
    clear(state) {
      state.simulations = [];
      state.idSimulation = 1;
    }
  }
});
// export de l'objet [store]
export default store;

注释

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

请注意,该数据存储将在各个视图之间共享,且其内容具有响应式特性:当内容发生更改时,使用该数据存储的视图会自动更新。在我们的应用程序中,只有 [simulations] 元素需要具备响应式特性,而 [simulationId] 元素则不需要。出于方便起见,我们仍将该元素保留在数据存储中;

  • 第 14–40 行:第 8–13 行中 [state] 对象允许的变异操作。请注意,这些操作始终将第 8–13 行中的 [state] 对象作为其第一个参数;
    • 第 16 行:[deleteSimulation] 变异操作允许您通过指定 [index] 编号来删除一个模拟;
    • 第 25 行:[addSimulation] 变异操作允许您向模拟数组中添加新的模拟;
    • 第 35 行:[clear] 操作将重置第 8–13 行中的 [state] 对象;

18.3.5. 路由文件 [router]

路由文件如下:


// imports
import Vue from 'vue'
import VueRouter from 'vue-router'
// les vues
import Authentification from './views/Authentification'
import CalculImpot from './views/CalculImpot'
import ListeSimulations from './views/ListeSimulations'
 
// plugin de routage
Vue.use(VueRouter)
 
// les routes de l'application
const routes = [
  // authentification
  {
    path: '/', name: 'authentification', component: Authentification
  },
  // calcul de l'impôt
  {
    path: '/calcul-impot', name: 'calculImpot', component: CalculImpot
  },
  // liste des simulations
  {
    path: '/liste-des-simulations', name: 'listeSimulations', component: ListeSimulations
  },
  // fin de session
  {
    path: '/fin-session', name: 'finSession', component: Authentification
  }
]
 
// le routeur
const router = new VueRouter({
  // les routes
  routes,
  // le mode d'affichage des routes dans le navigateur
  mode: 'history',
})
 
// export du router
export default router

注释

  • 第 16 行:应用程序启动时,会显示 [Authentication] 视图,因为其 URL 是根路径 [/]
  • 第 20 行:当请求 URL [/tax-calculation] 时,将显示 [TaxCalculation] 视图;
  • 第 24 行:当请求 URL [/simulation-list] 时,将显示 [SimulationList] 视图;
  • 第 28 行:当请求 URL [/end-session] 时,显示 [Authentication] 视图;
  • 第 33–38 行:创建了一个 [router] 对象,其中包含这些路由(第 35 行)以及用于 URL 管理的 [history] 模式(第 37 行);
  • 第 41 行:导出此路由器;

18.3.6. 主脚本 [main.js]

[main.js] 脚本如下:


// imports
import Vue from 'vue'
 
// vue principale
import Main from './views/Main.vue'
 
// plugin [bootstrap-vue]
import BootstrapVue from 'bootstrap-vue'
Vue.use(BootstrapVue);
 
// CSS bootstrap
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
 
// routeur
import router from './router'
 
// plugin [config]
import config from './config';
import pluginConfig from './plugins/pluginConfig'
Vue.use(pluginConfig, config)
 
// instanciation couche [dao]
import Dao from './couches/Dao';
const dao = new Dao(config.axios);
 
// plugin [dao]
import pluginDao from './plugins/pluginDao'
Vue.use(pluginDao, dao)
 
// instanciation couche [métier]
import Métier from './couches/Métier';
const métier = new Métier();
 
// plugin [métier]
import pluginMétier from './plugins/pluginMétier'
Vue.use(pluginMétier, métier)
 
// store Vuex
import store from './store'
 
// démarrage de l'UI
new Vue({
  el: '#app',
  // le routeur
  router: router,
  // le store Vuex
  store: store,
  // la vue principale
  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 行:我们对 [business] 层重复相同的操作序列。[Business] 类的构造函数将 [taxAdminData] 作为参数,该数据代表税务管理数据。目前我们尚未拥有此数据,因此第 33 行的 [business] 对象需要在后续步骤中进行初始化;
  • 第 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] 的代码如下:


<!-- définition HTML de la vue -->
<template>
  <div class="container">
    <b-card>
      <!-- jumbotron -->
      <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>
      <!-- erreur requête HTTP -->
      <b-alert
        show
        variant="danger"
        v-if="showError"
      >L'erreur suivante s'est produite : {{error.message}}</b-alert>
      <!-- vue courante -->
      <router-view v-if="showView" @loading="mShowLoading" @error="mShowError" />
      <!-- loading -->
      <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
  name: "app",
  // inner state
  data() {
    return {
      // controls waiting alert
      showLoading: false,
      // controls error alert
      showError: false,
      // controls the display of the current routing view
      showView: true,
      // an error message
      error: ""
    };
  },
  // event managers
  methods: {
    // asynchronous request error
    mShowError(error) {
      // eslint-disable-next-line
      console.log("Main evt error");
      // error msg is displayed
      this.error = error;
      this.showError = true;
      // hide the routed view
      this.showView = false;
      // we hide the waiting message
      this.showLoading = false;
    },
    // whether or not to display a waiting icon
    mShowLoading(value) {
      // eslint-disable-next-line
      console.log("Main evt showLoading");
      // whether or not to display the waiting alert
      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] 方法处理路由视图发出的 [error] 事件(第 23 行);
  • 第 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]视图的代码如下:


<!-- definition HTML of the routed view layout -->
<template>
  <!-- line -->
  <div>
    <b-row>
      <!-- three-column zone on the left -->
      <b-col cols="3" v-if="left">
        <slot name="left" />
      </b-col>
      <!-- nine-column zone on the right -->
      <b-col cols="9" v-if="right">
        <slot name="right" />
      </b-col>
    </b-row>
  </div>
</template>
 
<script>
  export default {
    // paramètres de la vue
    props: {
      // contrôle la colonne de gauche
      left: {
        type: Boolean
      },
      // contrôle la colonne de droite
      right: {
        type: Boolean
      }
    }
  };
</script>

评论

  • [Layout] 视图允许您将路由视图拆分为两个区域:
    • 左侧为一个 3 列的 Bootstrap 区域(第 7–9 行)。该区域将显示导航菜单(若存在);
    • 右侧为一个 9 列区域(第 11–13 行)。该区域将显示路由视图提供的信息;

18.4.3. [身份验证]视图

身份验证视图如下所示:

Image

该视图由 [Layout] 派生而来,通过移除左侧列仅显示右侧列。

其代码如下:


<!-- définition HTML de la vue -->
<template>
  <Layout :left="false" :right="true">
    <template slot="right">
      <!-- formulaire HTML - on poste ses valeurs avec l'action [authentifier-utilisateur] -->
      <b-form @submit.prevent="login">
        <!-- titre -->
        <b-alert show variant="primary">
          <h4>Bienvenue. Veuillez vous authentifier pour vous connecter</h4>
        </b-alert>
        <!-- 1ère ligne -->
        <b-form-group label="Nom d'utilisateur" label-for="user" label-cols="3">
          <!-- zone de saisie user -->
          <b-col cols="6">
            <b-form-input type="text" id="user" placeholder="Nom d'utilisateur" v-model="user" />
          </b-col>
        </b-form-group>
        <!-- 2ième ligne -->
        <b-form-group label="Mot de passe" label-for="password" label-cols="3">
          <!-- zone de saisie password -->
          <b-col cols="6">
            <b-input type="password" id="password" placeholder="Mot de passe" v-model="password" />
          </b-col>
        </b-form-group>
        <!-- 3ième ligne -->
        <b-alert
          show
          variant="danger"
          v-if="showError"
          class="mt-3"
        >L'erreur suivante s'est produite : {{message}}</b-alert>
        <!-- bouton de type [submit] sur une 3ième ligne -->
        <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>
 
<!-- dynamique de la vue -->
<script>
import Layout from "./Layout";
export default {
  // component status
  data() {
    return {
      // user
      user: "",
      // password
      password: "",
      // controls the display of an error msg
      showError: false,
      // the error message
      message: "",
      // session started
      sessionStarted: false
    };
  },
 
  // components used
  components: {
    Layout
  },
 
  // calculated properties
  computed: {
    // valid entries
    valid() {
      return this.user && this.password && this.sessionStarted;
    }
  },
 
  // event managers
  methods: {
    // ----------- authentication
    async login() {
      try {
        // start waiting
        this.$emit("loading", true);
        // blocking server authentication
        const response = await this.$dao.authentifierUtilisateur(
          this.user,
          this.password
        );
        // end of loading
        this.$emit("loading", false);
        // response analysis
        if (response.état != 200) {
          // error is displayed
          this.message = response.réponse;
          this.showError = true;
          return;
        }
        // no error
        this.showError = false;
        // --------- we now request data from the tax authorities
        // start waiting
        this.$emit("loading", true);
        // blocking request to the server
        const response2 = await this.$dao.getAdminData();
        // end of loading
        this.$emit("loading", false);
        // response analysis
        if (response2.état != 1000) {
          // error is displayed
          this.message = response2.réponse;
          this.showError = true;
          return;
        }
        // no error
        this.showError = false;
        // the received data is stored in the [business] layer
        this.$métier.setTaxAdminData(response2.réponse);
        // we move on to the tax calculation view
        this.$router.push({ name: "calculImpot" });
      } catch (error) {
        // the error is traced back to the main component
        this.$emit("error", error);
      }
    }
  },
  // life cycle: the component has just been created
  created() {
    // eslint-disable-next-line
    console.log("authentification", "created");
    // start a jSON session with the server
    // start waiting
    this.$emit("loading", true);
    // initialize the session with the server - asynchronous request
    // we use the promise rendered by the [dao] layer methods
    this.$dao
      // initialize a jSON session
      .initSession()
      // we got the answer
      .then(response => {
        // end waiting
        this.$emit("loading", false);
        // response analysis
        if (response.état != 700) {
          // error is displayed
          this.message = response.réponse;
          this.showError = true;
          return;
        }
        // the session has started
        this.sessionStarted = true;
      })
      // in case of error
      .catch(error => {
        // the error is traced back to the [Main] view
        this.$emit("error", error);
      });
  }
};
</script>

评论

  • 第 3 行:[Authentication] 视图仅使用 [Layout] 的右侧列(第 3 行和第 4 行);
  • 第 6–38 行:生成上图中区域 1 的 Bootstrap 表单;
  • 第 6 行:当用户点击第 35 行的 [submit] 按钮时,会触发 [@submit] 事件。[prevent] 修饰符确保在 [submit] 时页面不会重新加载。我们也可以这样写:
    • 一个未处理 [submit] 事件的 <b-form> 标签;
    • 一个带有 [@click='login'] 事件且不带 [type='submit'] 属性的 <b-button> 标签;

这种做法同样可行。所选方案的优势在于,表单不仅可以通过点击 [Submit] 按钮提交,在输入字段中按下 [Enter] 键也能触发提交。因此,出于用户便利的考虑,此处选择了 [<b-form @submit.prevent="login">] 方案;

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

Image

  • 第 35 行:[提交] 按钮并非始终处于活动状态。其状态取决于第 71–73 行中计算生成的 [valid] 属性。当满足以下条件时,[valid] 属性为 true:
    • 表单的 [user, password] 字段中有内容;
    • JSON会话已启动。初始时,该会话尚未启动(第59行),因此[Validate]按钮处于不可用状态。
  • 第 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 行:当 [Authentication] 视图创建完成时(尽管可能尚未显示),[created] 函数会被执行。随后在后台初始化与服务器的 JSON 会话。我们知道这是与税费计算服务器进行交互的首个操作。为此,我们使用应用程序的 [dao] 层(第 134 行)。 该层中的所有方法均为异步。此处,我们使用 [$dao.initSession] 方法返回的 Promise,该方法用于与服务器初始化 JSON 会话。
  • 第 138–150 行:服务器返回无错误响应时执行的代码;
  • 第 142 行:我们检查响应的 [status] 属性。若操作成功,该属性必须为 [700]。否则,表示发生错误,具体原因可在 [response.response] 属性中查阅(第 144 行)。随后,我们在视图中显示错误信息(第 145 行);
  • 第 149 行:我们注意到 JSON 会话已启动;
  • 第 152–155 行:发生错误时执行的代码。该错误将传播至父视图 [Main]
    • 将显示该错误;
    • 隐藏等待提示;
    • 隐藏路由视图,即 [Authentication] 视图;
  • 第 79–124 行:[login] 方法处理 [Validate] 按钮的点击事件;
  • 第 79 行:该方法前缀了 [async] 关键字,以便在第 84 行和第 103 行使用 [await] 关键字;
  • 第 84–87 行:对 [$dao.authenticateUser(user, password)] 方法的阻塞调用。我们本可以像在 [created] 函数中那样使用 [Promise]。 我们希望丰富界面风格。由于所有 HTTP 请求都设置了 2 秒的 [timeout],因此不会阻塞用户。用户无需等待太久。此外,在服务器返回响应之前,用户无法进行任何操作,因为 [Validate] 按钮在此之前始终处于不可用状态;
  • 第 91 行:税务计算服务器发送 JSON 响应,所有响应的结构均为 [{‘action’:action, ‘status’:val, ‘response’:response}]。如果 [status==200],则表示身份验证成功。否则,将显示错误消息(第 93-94 行);
  • 第 98 行:隐藏之前操作产生的任何错误消息;
  • 第 99–116 行:现在向服务器请求计算税款所需的税务机关数据。在 [this.$métier] 中,我们有一个 [Métier] 类的实例,由于尚未获取到这些数据,该实例目前无法执行任何操作;
  • 第 103 行:通过阻塞操作向服务器请求税务机关数据;
  • 第 107–112 行:分析服务器的响应。其状态值必须为 1000;否则即发生错误。若发生错误,则显示错误信息(第 109–110 行);
  • 第 113–118 行:如果操作成功,我们将:
    • 隐藏错误信息(第 114 行);
    • 将税务机关数据传递给 [业务] 层(第 116 行);
    • 显示 [CalculImpot] 视图,第 118 行。请注意,[this.$router] 指代应用程序路由器。使用 [push] 方法设置下一个路由视图。此处我们通过其 [name] 属性进行引用。我们也可以通过其 [path] 属性进行引用。该信息位于路由文件中:

// calcul de l'impôt
  {
    path: '/calcul-impot', name: 'calculImpot', component: CalculImpot
  },
  • 第 119–122 行:当两个 HTTP 请求中的任一个失败(服务器未找到、超时等)时,[catch] 代码块会被触发。随后,错误会被上报给父视图 [Main],该视图将显示错误信息,隐藏加载提示,并隐藏 [Authentication] 视图;

18.4.4. [CalculImpot] 视图

[CalculImpot]视图如下:

Image

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

[TaxCalculation] 视图的代码如下:


<!-- definition HTML of the view -->
<template>
  <div>
    <Layout :left="true" :right="true">
      <!-- tax calculation form on the right -->
      <FormCalculImpot slot="right" @resultatObtenu="handleResultatObtenu" />
      <!-- left-hand navigation menu -->
      <Menu slot="left" :options="options" />
    </Layout>
    <!-- display area for tax calculation results under the form -->
    <b-row v-if="résultatObtenu" class="mt-3">
      <!-- empty three-column zone -->
      <b-col cols="3" />
      <!-- nine-column zone -->
      <b-col cols="9">
        <b-alert show variant="success">
          <span v-html="résultat"></span>
        </b-alert>
      </b-col>
    </b-row>
  </div>
</template>
 
<script>
// imports
import FormCalculImpot from "./FormCalculImpot";
import Menu from "./Menu";
import Layout from "./Layout";
 
export default {
  // état interne
  data() {
    return {
      // options du menu
      options: [
        {
          text: "Liste des simulations",
          path: "/liste-des-simulations"
        },
        {
          text: "Fin de session",
          path: "/fin-session"
        }
      ],
      // résultat du calcul de l'impôt
      résultat: "",
      résultatObtenu: false
    };
  },
  // composants utilisés
  components: {
    Layout,
    FormCalculImpot,
    Menu
  },
  // méthodes de gestion des évts
  methods: {
    // résultat du calcul de l'impôt
    handleResultatObtenu(résultat) {
      // on construit le résultat en chaîne 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;
      // affichage du résultat
      this.résultatObtenu = true;
      // ---- maj du store [Vuex]
      // une simulation de +
      this.$store.commit("addSimulation", résultat);
    }
  }
};
</script>

评论

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

Image

  • 第 11 行:仅当 [resultObtained] 属性(第 47 行)为 [true] 时,才会显示结果;
  • 第 34–48 行:视图状态:
    • [options]:导航菜单选项列表。该数组作为参数传递给第8行的[Menu]组件;
    • [result]:税费计算的结果。该结果是一个 HTML 字符串。因此,第 17 行使用了 [v-html] 指令来显示它;
    • [resultObtained]:控制结果显示的布尔值,第 11 行;
  • 第 59–81 行:[handleResultatObtenu] 方法用于显示子视图 [FormCalculImpot](第 6 行)传入的税费计算结果。该结果是一个包含 [tax, discount, reduction, surcharge, rate, married, children, salary] 属性的对象;
  • 第 61–75 行:将 [tax, discount, reduction, surcharge, rate] 对象插入到由模板第 17 行渲染的 HTML 文本中;
  • 第 77 行:显示该结果;
  • 第 80 行:调用 Vuex 存储的 [addSimulation] 方法,将 [result] 添加到存储中已有的模拟结果中;

18.4.5. 导航菜单 [Menu]

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

Image

[Menu]视图的代码如下:


<!-- definition HTML of the view -->
<template>
  <!-- bootstrap vertical menu -->
  <b-nav vertical>
    <!-- menu options -->
    <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 {
  // paramètres de la vue
  props: {
    options: {
      type: Array
    }
  }
};
</script>

评论

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

18.4.6. [FormCalculImpot]视图

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

Image

其代码如下:


  <!-- définition HTML de la vue -->
  <template>
  <!-- formulaire HTML -->
  <b-form @submit.prevent="calculerImpot" class="mb-3">
    <!-- message sur 12 colonnes sur fond bleu -->
    <b-alert show variant="primary">
      <h4>Remplissez le formulaire ci-dessous puis validez-le</h4>
    </b-alert>
    <!-- éléments du formulaire -->
    <!-- première ligne -->
    <b-form-group label="Etes-vous marié(e) ou pacsé(e) ?" label-cols="4">
      <!-- boutons radio sur 5 colonnes-->
      <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>
    <!-- deuxième ligne -->
    <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"
      />
      <!-- message d'erreur éventuel -->
      <b-form-invalid-feedback :state="enfantsValide">Vous devez saisir un nombre positif ou nul</b-form-invalid-feedback>
    </b-form-group>
    <!-- troisème ligne -->
    <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"
      />
      <!-- message d'erreur éventuel -->
      <b-form-invalid-feedback :state="salaireValide">Vous devez saisir un nombre positif ou nul</b-form-invalid-feedback>
    </b-form-group>
    <!-- quatrième ligne, bouton [submit] sur 5 colonnes -->
    <b-col cols="5">
      <b-button type="submit" variant="primary" :disabled="formInvalide">Valider</b-button>
    </b-col>
  </b-form>
</template>
 
<!-- script -->
<script>
export default {
  // inner state
  data() {
    return {
      // married or not
      marié: "non",
      // number of children
      enfants: "",
      // annual salary
      salaire: ""
    };
  },
  // calculated internal state
  computed: {
    // form validation
    formInvalide() {
      return (
        // disabled salary
        !this.salaireValide ||
        // or disabled children
        !this.enfantsValide ||
        // or tax data not obtained
        !this.$métier.taxAdminData
      );
    },
    // salary validation
    salaireValide() {
      // must be numeric >=0
      return Boolean(this.salaire.match(/^\s*\d+\s*$/));
    },
    // child validation
    enfantsValide() {
      // must be numeric >=0
      return Boolean(this.enfants.match(/^\s*\d+\s*$/));
    }
  },
  // event manager
  methods: {
    calculerImpot() {
      // tax is calculated using the [business] layer
      const résultat = this.$métier.calculerImpot(
        this.marié,
        this.enfants,
        this.salaire
      );
      // eslint-disable-next-line
      console.log("résultat=", résultat);
      // complete the result
      résultat.marié = this.marié;
      résultat.enfants = this.enfants;
      résultat.salaire = this.salaire;
      // the [resultatObtenu] event is issued
      this.$emit("resultatObtenu", résultat);
    }
  }
};
</script>

评论

  • 第4–51行:Bootstrap表单;
  • 第 11–17 行:一组单选按钮及其标签;
  • 第 14–15 行:<b-form-radio> 标签显示一个单选按钮:
    • 第 14 行:[v-model] 指令确保当按钮被点击时,第 61 行的 [married] 属性将被设置为 [yes](属性 [value="yes"]);
    • 第 15 行:[v-model] 指令确保当按钮被点击时,第 61 行上的 [married] 属性将被设置为 [no](属性 [value="no"]);
  • 第 19–29 行:输入子女数量的区域:
    • 第 24 行:子女数量的输入框与第 63 行的 [children] 属性相关联;
    • 第 25 行:通过第 87–89 行中的计算属性 [validChildren] 验证输入的有效性;
    • 第 28 行:确保若输入无效则显示错误信息;
  • 第 31–45 行:年薪输入部分:
    • 第 35 行:在输入框正下方显示帮助信息;
    • 第 41 行:薪资输入与第 65 行的 [salary] 属性相关联;
    • 第 42 行:通过第 82–85 行中的计算属性 [validSalary] 验证输入的有效性;
    • 第 45 行:若输入内容无效,则显示错误信息;
  • 第 48–50 行:一个 [submit] 按钮。点击此按钮或使用 [Enter] 键确认输入时,将执行 [calculateTax] 方法(第 94 行);
    • 第 49 行:按钮的启用/禁用状态由第 71–80 行中的计算属性 [formInvalid] 控制;
  • 第 71–80 行:若满足以下条件,表单即为有效:
    • 子女数量有效;
    • 工资数值有效;
    • 应用程序已从服务器获取税务管理数据以计算税款。请注意,该数据存储在 [$métier.taxAdminData] 属性中。由于该数据是在视图显示的同时异步请求的,因此 [FormCalculImpot] 视图可在数据获取前显示。在此,我们确保在数据检索完成前,用户无法点击 [Validate] 按钮;
  • 第 94–109 行:税费计算方法:
    • 第 96–100 行:[business] 层执行此计算。这是一项同步计算。一旦 [taxAdminData] 被检索,客户端 [View] 就不再需要与服务器通信。所有操作都在本地完成。我们获得一个具有 [tax, discount, surcharge, reduction, rate] 属性的 [result] 对象;
    • 第 104–106 行:将属性 [married, children, salary] 添加到结果中;
    • 第 108 行:通过 [resultatObtenu] 事件将结果传递给父视图 [CalculImpot]。该视图负责显示结果;

18.4.7. [SimulationList] 视图

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

Image

该视图的代码如下:


<!-- définition HTML de la vue -->
<template>
  <div>
    <!-- mise en page -->
    <Layout :left="true" :right="true">
      <!-- simulations dans colonne de droite -->
      <template slot="right">
        <template v-if="simulations.length==0">
          <!-- pas de simulations -->
          <b-alert show variant="primary">
            <h4>Votre liste de simulations est vide</h4>
          </b-alert>
        </template>
        <template v-if="simulations.length!=0">
          <!-- il y a des simulations -->
          <b-alert show variant="primary">
            <h4>Liste de vos simulations</h4>
          </b-alert>
          <!-- tableau des simulations -->
          <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 de navigation dans colonne de gauche -->
      <Menu slot="left" :options="options" />
    </Layout>
  </div>
</template>
 
<script>
  // imports
  import Layout from "./Layout";
  import Menu from "./Menu";
  export default {
    // composants
    components: {
      Layout,
      Menu
    },
    // état interne
    data() {
      return {
        // options du menu de navigation
        options: [
          {
            text: "Calcul de l'impôt",
            path: "/calcul-impot"
          },
          {
            text: "Fin de session",
            path: "/fin-session"
          }
        ],
        // paramètres de la table 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" }
        ]
      };
    },
    // état interne calculé
    computed: {
      // liste des simulations prise dans le store Vuex
      simulations() {
        return this.$store.state.simulations;
      }
    },
    // méthodes
    methods: {
      supprimerSimulation(index) {
        // eslint-disable-next-line
        console.log("supprimerSimulation", index);
        // suppression de la simulation n° [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 行:HTML 表格由 <b-table> 标签生成;
    • 第 20 行:模拟表由第 74–76 行中的计算属性 [simulations] 提供;
    • 第 20 行:HTML 表格由第 58–69 行中的计算属性 [fields] 配置。第 67 行:[action] 键列是 HTML 表格的最后一列;
    • 第 21–23 行:HTML 表格最后一列的模板;
    • 第 22 行:此处放置一个链接按钮。点击时将调用方法 [deleteSimulation(data.index)],其中 [data] 代表当前行(第 21 行)。[data.index] 代表该行在显示行列表中的序号;
  • 第 28 行:生成导航菜单。其选项由第 47–56 行中的 [options] 属性提供;
  • 第 80–85 行:处理 HTML 页面上 [Delete] 链接点击事件的方法;
    • 第 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] 客户端已部署在测试服务器上,网址为 [http://localhost:8080/client-vuejs-impot/]

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

Image

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


// vue.config.js
module.exports = {
  // l'URL de service du client [vuejs] du serveur de calcul de l'impôt
  publicPath: '/client-vuejs-impot/'
}

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


// imports
import Vue from 'vue'
import VueRouter from 'vue-router'
// les vues
import Authentification from './views/Authentification'
import CalculImpot from './views/CalculImpot'
import ListeSimulations from './views/ListeSimulations'
 
// plugin de routage
Vue.use(VueRouter)
 
// les routes de l'application
const routes = [
  // authentification
  {
    path: '/', name: 'authentification', component: Authentification
  },
  // calcul de l'impôt
  {
    path: '/calcul-impot', name: 'calculImpot', component: CalculImpot
  },
  // liste des simulations
  {
    path: '/liste-des-simulations', name: 'listeSimulations', component: ListeSimulations
  },
  // fin de session
  {
    path: '/fin-session', name: 'finSession', component: Authentification
  }
]
 
// le routeur
const router = new VueRouter({
  // les routes
  routes,
  // le mode d'affichage des routes dans le navigateur
  mode: 'history',
  // l'URL de base de l'application
  base: '/client-vuejs-impot/'
})
 
// export du router
export default router
  • 第 39 行:我们告诉路由器,第 13–30 行定义的路由路径相对于第 39 行定义的路径。例如,第 20 行中的路径 [/calcul-import] 将变为 [/client-vuejs-import/calcul-import]

然后我们可以再次测试 [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]中,当请求 URL [https://localhost:80/client-vue-js-impot/] 时,将使用 [index.html] 文件;

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

步骤 3

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


<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 Web 服务器配置文件。如果我们不包含该文件,而是直接请求 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] [提交] 按钮仍然处于禁用状态。让我们查看 [FormCalculImpot] 视图的代码:


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

在第 2 行,我们可以看到其启用/禁用状态取决于 [formInvalide] 属性。这是以下计算属性:


formInvalide() {
      return (
        // salaire invalide
        !this.salaireValide ||
        // ou enfants invalide
        !this.enfantsValide ||
        // ou données fiscales pas obtenues
        !this.$métier.taxAdminData
      );
},

第 8 行表明,表单要有效,必须已获取税费数据。然而,这些数据是在 [Authentication] 视图的验证过程中获取的,而用户已“跳过”了该步骤。因此,他们将无法提交表单。如果他们能够提交,服务器会返回一条错误消息,指出他们未通过身份验证。验证必须始终在服务器端进行。 客户端验证总是可以被绕过的。只需使用像 [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 处理得相当好。当这些 URL 被调用时,应用程序路由器会识别到它们。因此,在视图最终显示之前,我们可以进行干预。我们将在 [vuejs-21] 项目中探讨这一点。

还有一点需要考虑。假设用户已按照规则运行了几次模拟:

Image

现在,让我们按 F5 键刷新页面:

Image

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

以下项目 [vuejs-21] 旨在引入两项改进:

  • 验证用户输入的 URL;
  • 即使用户输入了 URL,也能保持应用程序的状态。上文中,我们可以看到模拟列表已丢失;