Skip to content

19. Vue.js客户端的改进

19.1. Introduction

我们将使用开发服务器测试项目 [vuejs-21]。因此,我们需要服务器再次发送 CORS 头部信息。 因此,税务计算服务器第14版中的[config.json]文件必须支持以下头部:

Image

项目 [vuejs-21] 最初是通过复制项目 [vuejs-20] 创建的。随后将其修改为 [3]

出现新的文件:

  • [session.js]:导出一个名为 [session] 的对象,该对象将封装当前会话的信息;
  • [pluginSession]:在视图的 [$session] 属性中提供上述 [session] 对象;
  • [NotFound.vue]:当用户手动请求不存在的 URL 时,显示一个新视图;

将修改以下文件:

  • [main.js]:将初始化当前会话,当用户手动输入URL时,将恢复该会话;
  • [router.js]:添加了处理用户手动输入的 URL 的校验逻辑;
  • [store.js]:新增了一项变更;
  • [config.js]:新增了一项配置;
  • 新增了多种视图,主要用于在应用程序生命周期的关键时刻保存当前会话。随后,每当用户手动输入 URL 时,该会话都会被恢复;

19.2. [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: {
    // 删除索引号第 n 行
    deleteSimulation(state, index) {
      ...
    },
    // 添加模拟
    addSimulation(state, simulation) {
      ...
    },
    // 清理状态
    clear(state) {
      // 无更多模拟
      state.simulations = [];
      // 模拟编号重置为0
      state.idSimulation = 0;
    }
  }
});
// 导出对象 [store]
export default store;
  • 第24-29行:[clear]的修改删除了已保存的模拟列表,并将最后一次模拟的编号重置为0。

19.3. 会话

需要会话的原因在于:当用户在浏览器地址栏中输入 URL 时,脚本 [main.js] 会被重新执行。而该脚本包含以下指令:


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

该指令会导入以下文件 [./store]


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

// Vuex 存储
const store = new Vuex.Store({
  state: {
    // 模拟表
    simulations: [],
    // 最后一次模拟的编号
    idSimulation: 0
  },
  mutations: {
    ...
  }
});
// 导出对象 [store]
export default store;

从第7至13行可见,导入的是一张空的模拟数据表。因此,如果在用户在浏览器地址栏中输入 URL 之前存在模拟数据,之后这些数据便会消失。解决方案是:

  • 使用一个会话来存储需要保留的信息,以防用户手动输入 URL;
  • 在应用程序的关键时刻将其保存;
  • [main.js] 中恢复这些信息,该脚本会在用户手动输入 URL 时始终被执行

脚本 [./session] 如下:


// 导入 Vuex 组件
import store from './store'
// 导入配置
import config from './config';

// 对象 [session]
const session = {
  // 会话已启动
  started: false,
  // 身份验证
  authenticated: false,
  // 保存时间
  saveTime: "",
  // 层 [métier]
  métier: null,
  // Vuex状态
  state: null,

  // 将会话保存到字符串中jSON
  save() {
    // 向会话添加一些属性
    this.saveTime = Date.now();
    this.state = store.state;
    // 将其转换为 jSON
    const json = JSON.stringify(this);
    // 将其存储在浏览器中
    localStorage.setItem("session", json);
    // eslint-disable-next-line no-console
    console.log("session save", json);
  },

  // 恢复会话
  restore() {
    // 从浏览器中检索会话 jSON
    const json = localStorage.getItem("session")
    // 如果已获取到某些内容
    if (json) {
      // 恢复会话中的所有密钥
      const restore = JSON.parse(json);
      for (var key in restore) {
        if (restore.hasOwnProperty(key)) {
          this[key] = restore[key];
        }
      }
      // 如果自会话开始以来已超过一定时间未操作,则重置为初始状态
      let durée = Date.now() - this.saveTime;
      if (durée > config.duréeSession) {
        // 清空会话——该会话也将被保存
        session.clear();
      } else {
        // 重新生成 Vuex 数据存储
        store.replaceState(JSON.parse(JSON.stringify(this.state)));
      }
    }
    // eslint-disable-next-line no-console
    console.log("session restore", this);
  },

    // 清理会话
  clear() {
    // eslint-disable-next-line no-console
    console.log("session clear");
    // 清空会话中的某些字段
    this.authenticated = false;
    this.saveTime = "";
    this.started = false;
    if (this.métier) {
      // 重置字段 [taxAdminData]
      this.métier.taxAdminData = null;
    }
    // Vuex 存储也已清空
    store.commit("clear");
    // 保存新会话
    this.save();
  },
}

// 导出对象 [session]
export default session;

注释

  • 第 2 行:该会话还将封装存储 [Vuex](模拟列表、上次执行的模拟编号);
  • 第 7-17 行:会话保存的信息:
    • [started]:与服务器的 jSON 会话是否已启动;
    • [authenticated]:用户是否已通过身份验证;
    • [saveTime]:上次保存的日期(以毫秒为单位);
    • [métier]:对 [métier] 层的引用。该层包含用于计算税款的数据 [taxAdminData]
    • [state]:存储 [Vuex] 的状态(模拟列表、最近一次模拟的编号);
  • 第20-30行:方法[save]将会话本地保存至运行应用程序的浏览器中;
    • 第 22 行:记录保存时间;
    • 第 23 行:从存储 [Vuex] 中检索 [state]
    • 第25行:创建会话的字符串jSON;
    • 第27行:将其与密钥[session]关联,并存储在浏览器本地;
  • 第33-57行:方法[restore]用于从浏览器上的本地备份恢复会话;
    • 第 35 行:检索本地备份 jSON;
    • 第 37 行:如果检索到了内容;
    • 第39-44行:重建对象[session]
    • 第 46 行:计算当前时间与上次备份之间的时间间隔;
    • 第47-50行:如果该时间间隔超过配置中设定的[config.duréeSession]值,则重置会话(第49行)并在此过程中进行备份;
    • 第 52 行:否则,重新生成存储 [Vuex] 的属性 [state]
  • 第 60-75 行:方法 [clear] 重置会话;
    • 第64-70行:会话属性被重置为初始值;
    • 第 72 行:存储 [Vuex] 也一并重置;
    • 第 74 行:保存新会话;

19.4. 配置文件 [config]

文件 [./config] 的变化如下:


// 使用库 [axios]
const axios = require('axios');
// 请求超时 HTTP
axios.defaults.timeout = 2000;
...

// 导出配置
export default {
  // 对象 [axios]
  axios: axios,
  // 会话最大空闲时间:5 分钟 = 300 秒 = 300000 毫秒
  duréeSession: 300000
}
  • 第12行:我们将像管理Web会话那样管理应用程序的会话。此处将最大空闲时间设置为5分钟;

19.5. 插件 [pluginSession]

正如之前多次做过的那样,插件 [pluginSession] 将允许视图通过属性 [this.$session] 访问会话:


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

19.6. 主脚本 [main]

主脚本 [./main.js] 的演变如下:


// 启动日志
// eslint-disable-next-line no-console
console.log("main started");

// 导入
import Vue from 'vue'

...

// [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'

// 会话
import session from './session';
import pluginSession from './plugins/pluginSession'
Vue.use(pluginSession, session)

// 在重启前恢复会话
session.restore();

// 恢复 [métier] 层
if (session.métier && session.métier.taxAdminData) {
  métier.setTaxAdminData(session.métier.taxAdminData);
}

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

// 结束日志
// eslint-disable-next-line no-console
console.log("main terminated, session=", session);
  • 第 19 行:导入会话;
  • 第 20 行:导入其插件;
  • 第 21 行:将插件 [pluginSession] 集成到 [Vue] 中。执行此指令后,所有视图在其 [$session] 属性中都拥有会话;
  • 第27行:恢复会话。第11行导入的会话将使用其上次保存的内容进行初始化;
  • 第16行之后,视图拥有在第12行初始化的属性[$métier]。该属性不包含用于计算税款的[taxAdminData]信息;
  • 第30-32行:如果刚刚执行的恢复操作已恢复属性[session.métier.taxAdminData],则视图的属性[$métier]将使用该值进行初始化;

19.7. 路由文件 [router]

路由文件 [./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'
import NotFound from './views/NotFound'
// 会话
import session from './session'

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

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

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

// 路线验证
router.beforeEach((to, from, next) => {
  // eslint-disable-next-line no-console
  console.log("router to=", to, "from=", from);
  // 仅限经过身份验证的用户访问?
  if (to.meta.authenticated && !session.authenticated) {
    next({
      // 进入身份验证
      name: 'authentification',
    })
    // 返回事件循环
    return;
  }
  // 会话结束的特殊情况
  if (to.name === "finSession") {
    // 清理会话
    session.clear();
    // 跳转至视图 [authentification]
    next({
      name: 'authentification',
    })
    // 返回事件循环
    return;
  }
  // 其他情况 - 路由的下一个常规视图
  next();
})

// 路由器导出
export default router

注释

  • 第 16-38 行:某些路由已补充了额外信息;
  • 第 19 行:创建了一条新路径,用于跳转至视图 [Authentification]
  • 第21-24行:通往视图[CalculImpot]的路径现在具有属性[meta](该名称是强制要求的)。该对象的内容可以是任意值,由开发者确定;
  • 第 23 行:在 [meta] 中设置属性 [authenticated](该名称可任意设定)。这表示,若要访问视图 [CalculImpot],用户必须经过身份验证;
  • 第 26-29 行:对通往视图 [ListeSimulations] 的路由执行相同操作。在此处,用户同样必须经过身份验证;
  • 属性 [meta.authenticated] 将使我们能够验证:如果用户未经过身份验证,即使手动输入视图 [CalculImpot, ListeSimulations] 的 URL,也无法获取这些视图;
  • 第 51-76 行:在视图被路由之前会执行方法 [beforeEach]。此时正是进行验证的合适时机;
    • [to]:若不采取任何操作,则显示下一条路线;
    • [from]:最后显示的路线;
    • [next]:用于更改下一条显示路由的函数;
  • 第55行:检查下一条航线是否要求用户进行身份验证;
  • 第56-59行:如果需要且用户未经过身份验证,则将下一个路由更改为视图[Authentification]
  • 第64-73行:处理第30-32行中路由[finSession]的特殊情况。该路由没有关联的视图;
    • 第 66 行:将会话重置为初始值;
    • 第68-70:将视图[Authentification]设为下一个视图;
  • 第75行:若不属于上述两种情况,则直接跳转至路由文件中预设的路线;
  • 第35-37行:如果用户输入的路线与任何已知路线都不匹配,则预设视图[NotFound]。该视图在第8行被导入。路线按路由文件中的顺序进行验证。 因此,若执行到第36行,说明所请求的航线不属于第18-33行中的任何一条;

19.8. 视图 [NotFound]

如果用户输入的航线与任何已知航线都不匹配,则显示视图 [NotFound]

Image

该视图的代码如下:


<!-- 视图定义 HTML -->
<template>
  <!-- 版面设计 -->
  <Layout :left="true" :right="true">
    <!-- 右侧栏中的警报 -->
    <template slot="right">
      <!-- 黄色背景上的消息 -->
      <b-alert show variant="danger" align="center">
        <h4>Cette page n'existe pas</h4>
      </b-alert>
    </template>
    <!-- 左侧栏导航菜单 -->
    <Menu slot="left" :options="options" />
  </Layout>
</template>

<script>
// 导入
import Layout from "./Layout";
import Menu from "./Menu";
export default {
  // 组件
  components: {
    Layout,
    Menu
  },
  // 组件内部状态
  data() {
    return {
      // 导航菜单选项
      options: [
        {
          text: "Authentification",
          path: "/"
        }
      ]
    };
  },
  // 生命周期
  created() {
    // eslint-disable-next-line
    console.log("NotFound created");
    // 查看应提供哪些菜单选项
    if (this.$session.authenticated && this.$métier.taxAdminData) {
      // 用户可以进行模拟
      Array.prototype.push.apply(this.options, [
        {
          text: "Calcul de l'impôt",
          path: "/calcul-impot"
        },
        {
          text: "Liste des simulations",
          path: "/liste-des-simulations"
        }
      ]);
    }
  }
};
</script>

注释

  • 第 4 行:它使用了路由视图的两列;
  • 第6-11行:一条错误信息;
  • 第13行:导航菜单占据左侧栏;
  • 第31-36行:菜单的默认选项;
  • 第40-57行:视图创建时执行的代码;
  • 第44行:检查用户是否可以进行模拟;
  • 第45-55行:如果可以,则在导航菜单中添加两个选项,即需要经过身份验证且拥有可运行的QZXW2HTMLP001621ZQ图层(第46-55行);

19.9. 视图 [Authentification]

视图 [Authentification] 的变化如下:


<!-- 视图的定义 -->
<template>
  <Layout :left="false" :right="true">
    ...
  </Layout>
</template>

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

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

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

  // 事件处理程序
  methods: {
    // ----------- 身份验证
    async login() {
      try {
        // 开始等待
        this.$emit("loading", true);
        // 尚未通过身份验证
        this.$session.authenticated = false;
        // 服务器端阻塞式身份验证
        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.$session.authenticated = true;
        // --------- 现在请求税务管理数据
        // 初始时无数据
        this.$métier.setTaxAdminData(null);
        // 开始等待
        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);
      } finally {
        // 更新会话
        this.$session.métier = this.$métier;
        // 保存会话
        this.$session.save();
      }
    }
  },
  // 生命周期:组件刚刚创建
  created() {
    // eslint-disable-next-line
    console.log("Authentification created");
    // 用户能否进行模拟?
    if (
      this.$session.started &&
      this.$session.authenticated &&
      this.$métier.taxAdminData
    ) {
      // 那么用户可以进行模拟
      this.$router.push({ name: "calculImpot" });
      // 返回事件循环
      return;
    }
    // 如果会话 jSON 已启动,则不再重新启动
    if (!this.$session.started) {
      // 开始等待
      this.$emit("loading", true);
      // 与服务器初始化会话 - 异步请求
      // 使用 [dao] 层方法返回的 Promise
      this.$dao
        // 初始化会话 jSON
        .initSession()
        // 已获取响应
        .then(response => {
          // 等待结束
          this.$emit("loading", false);
          // 分析响应
          if (response.état != 700) {
            // 显示错误
            this.message = response.réponse;
            this.showError = true;
            // 返回事件循环
            return;
          }
          // 会话已启动
          this.$session.started = true;
        })
        // 发生错误时
        .catch(error => {
          // 将错误上报至视图 [Main]
          this.$emit("error", error);
        })
        // 无论何种情况
        .finally(() => {
          // 保存会话
          this.$session.save();
        });
    }
  }
};
</script>

注释

  • 已用黄色高亮标出使用本版本 [Vue.js] 客户端中引入的会话的语句;
  • 第 97、148 行:在 [login, created] 方法的末尾,无论这些方法中执行的 HTTP 查询结果如何(两种情况均包含 [finally] 子句),会话都会被保存;
  • 每当创建视图 [Authentification] 时,都会执行第 102-150 行中的方法 [created]。 如果用户手动输入了视图的URL,则会话将指导我们后续操作;
  • 第106-115行:如果jSON会话已启动,用户已通过身份验证且[this.$métier.taxAdminData]数据已初始化,则用户可直接跳转至税款计算表单(第112行);
  • 第117行:在上一版本中,曾使用方法[created]与服务器初始化会话jSON。若该步骤已完成,则无需重复执行;
  • 第42-66行:身份验证方法;
  • 第66行:若认证成功,则在会话中记录该状态;
  • 第67-92行:向服务器请求税务管理数据 [taxAdminData]
  • 第95行:该阶段结束时,无论操作是否成功,均更新会话的[métier]属性;

19.10. 视图 [CalculImpot]

视图 [CalculImpot] 的代码如下所示:


<!-- 视图 HTML 的定义 -->
<template>
  ...
</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
      ...
      // 一次模拟 +
      this.$store.commit("addSimulation", résultat);
      // 保存会话
      this.$session.save();
    }
  },
  // 生命周期
  created() {
    // eslint-disable-next-line
    console.log("CalculImpot created");
  }
};
</script>

注释

  • 第 45 行:计算出的模拟结果被添加到存储区 [Vuex] 中。这会影响包含该存储区属性 [state] 的会话。因此,我们保存了该会话(第 47 行);
  • 第51行:创建方法 [created],用于在日志中跟踪视图的创建;

19.11. 视图 [ListeSimulations]

视图 [ListeSimulations] 的变更如下:


<!-- 视图定义 -->
<template>
  ...
  </div>
</template>

<script>
// 导入
import Layout from "./Layout";
import Menu from "./Menu";
export default {
  // 组件
  components: {
    Layout,
    Menu
  },
  // 内部状态
  data() {
    ...
  },
  // 计算出的内部状态
  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);
      // 保存会话
      this.$session.save();
    }
  },
  // 生命周期
  created() {
    // eslint-disable-next-line
    console.log("ListeSimulations created");
  }
};
</script>

注释

  • 第 36 行:在删除第 34 行的模拟后,保存会话以反映此状态变化;
  • 第40-43行:继续跟踪视图的创建;

19.12. 项目执行

Image

测试时请检查以下几点:

  • 如果用户通过导航菜单中的链接以及操作按钮/链接“使用”该应用程序,则应用程序能够正常运行;
  • 如果用户手动输入 URL,应用程序仍能正常运行。请特别进行以下测试:
    • 进行模拟操作;
    • 进入视图 [ListeSimulations] 后,重新加载(F5)该视图。在之前的应用程序 [vuejs-20] 中,此时会丢失模拟数据。但在此处并非如此:已进行的模拟数据仍能正常保留;
  • 请查看日志以了解:
    • 脚本 [main] 在何时被执行。您会发现,每当用户手动输入 URL 时,该脚本都会被执行;
    • 视图是在何时创建的。您会发现,每当视图即将被显示时,它们就会被创建;
    • 路由机制的运作。每次路由前都会生成日志,其中会显示:
      • 您来自哪条路由;
      • 您将前往的路径;

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

作为练习,请按照 |在本地服务器上部署| 这一节的说明,将项目 [vuejs-21] 部署到本地 Laragon 服务器上。然后进行测试。

19.14. 移动版本的调试

理论上,使用 Bootstrap 应该能让我们获得一款可在不同设备上运行的应用程序:智能手机、平板电脑、笔记本电脑和台式电脑。这些设备之间的区别在于屏幕尺寸。

若在移动设备上测试 [vuejs-21] 版本,会发现视图显示混乱不堪。[vuejs-22] 版本已修正此问题。 所有修改均在视图模板中进行。主要工作是针对智能手机屏幕优化显示效果。一旦优化完成,借助Bootstrap框架,在更大尺寸的屏幕上也能流畅显示。

Image

19.14.1. 视图 [Main]

视图 [Main] 的演变如下:


<!-- 视图定义 HTML -->
<template>
  <div class="container">
    <b-card>
      <!-- jumbotron -->
      <b-jumbotron>
        <b-row>
          <b-col sm="4">
            <img src="../assets/logo.jpg" alt="Cerisier en fleurs" />
          </b-col>
          <b-col sm="8">
            <h1>Calculez votre impôt</h1>
          </b-col>
        </b-row>
      </b-jumbotron>
      ....
    </b-card>
  </div>
</template>

注释

  • 第8行:原为[cols=’4’]处,现改为[sm=’4’]。[sm]表示[small]。 智能手机屏幕属于此类别。其他类别包括 [xs=extra small, md=medium, lg=large, xl=extra large]
  • 第11行:同上;

19.14.2. 视图 [Layout]

视图 [Layout] 的演变如下:


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

19.14.3. 视图 [Authentification]

视图 [Authentification] 演变为:


<!-- 视图 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" description="Tapez admin">
          <!-- 用户输入区 -->
          <b-col sm="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" description="Tapez admin">
          <!-- 密码输入框 -->
          <b-col sm="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 sm="2">
            <b-button variant="primary" type="submit" :disabled="!valid">Valider</b-button>
          </b-col>
        </b-row>
      </b-form>
    </template>
  </Layout>
</template>

注释

  • 第 11 行和第 19 行:已删除 [label-cols] 属性,该属性曾用于为输入框的标签设置列数。在没有此属性的情况下,标签位于输入框上方。这更适合智能手机屏幕;

19.14.4. 视图 [CalculImpot]

视图 [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 sm="3" />
      <!-- 九列区域 -->
      <b-col sm="9">
        <b-alert show variant="success">
          <span v-html="résultat"></span>
        </b-alert>
      </b-col>
    </b-row>
  </div>
</template>

19.14.5. 视图 [FormCalculImpot]

视图 [FormCalculImpot] 演变为:


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

注释

  • 第15、23、35行:已删除[label-cols]属性;

此外,我们对有效性测试进行了调整:


...
// 计算出的内部状态
  computed: {
    // 表单验证
    formInvalide() {
      return (
        // 工资无效
        !this.salaire.match(/^\s*\d+\s*$/) ||
        // 或子女信息无效
        !this.enfants.match(/^\s*\d+\s*$/) ||
        // 或未获取税务数据
        !this.$métier.taxAdminData
      );
    },
    // 工资验证
    salaireValide() {
      // 必须为数字且大于等于0
      return Boolean(
        this.salaire.match(/^\s*\d+\s*$/) || this.salaire.match(/^\s*$/)
      );
    },
    // 子女信息验证
    enfantsValide() {
      // 必须为数字且大于等于0
      return Boolean(
        this.enfants.match(/^\s*\d+\s*$/) || this.enfants.match(/^\s*$/)
      );
    }
},
...

注释

  • 第19行:当未输入任何内容时,该输入被视为有效。这使得在视图初始显示时即可获得有效的输入。在上一版本中,该输入初始显示时会被标记为错误;
  • 第26行:同上;
  • 第5-14行:只有当两个输入框均有内容且均有效时,提交按钮才处于活动状态;

19.14.6. 视图 [Menu]

视图 [Menu] 的变更如下:


<!-- 视图定义 HTML -->
<template>
  <b-card class="mb-3">
    <!-- 垂直 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>
  </b-card>
</template>

注释

  • 第 3 行:添加 <b-card> 标签,为菜单添加细边框。这有助于在智能手机上更清晰地定位菜单;

19.14.7. 视图 [ListeSimulations]

视图 [ListeSimulations] 保持不变:


<!-- 视图 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>

注释

  • 第 20 行:请注意 [responsive] 属性,它使表格的显示能够适应屏幕大小:

Image

  • [2] 中,在小屏幕上,通过水平滚动条可显示表格;

19.14.8. 视图 [NotFound]

保持不变。

19.14.9. 移动端视图

Image

Image

:肯定还有可能实现更适合移动端的视图。我特别想到导航菜单可以改进,但还有其他方面。本文档的主要目的并非开发移动应用。如果是那样,我们可能会选择像 Ionic |https://ionicframework.com/| 这样的框架。