32. 第 12 版的 HTML 模式
我们在第 12 版开头曾提到,我们将分阶段开发该应用程序。当时我们写道:
- 基于HTML应用程序的视图,我们将定义Web应用程序需要实现的操作。此处我们将使用实际视图,但也可以是纸面上的视图;
- 基于这些操作,我们将定义应用程序 URL 的服务;
- 我们将通过一个提供 jSON 的服务器来实现这些 URL 服务。这样可以定义 Web 服务器的框架,而无需考虑要提供的 HTML 页面。 我们将使用 Postman 测试这些 URL 服务;
- 随后将使用控制台客户端测试我们的 jSON 服务器;
- 一旦 jSON 服务器通过验证,我们将开始编写 HTML 应用程序;
目前 jSON 和 XML 服务器已正常运行。现在可以转到 HTML 服务器。 我们将看到,该服务器沿用了为 jSON / XML 服务器开发的所有架构,并为其增加了 HTML 视图管理功能。
32.1. MVC架构
我们将按以下方式实现所谓的 MVC 架构模型(模型 – 视图 – 控制器):
处理客户端请求的流程如下:
- 1 - 请求
请求的 URL 将采用 http://machine:port/action/param1/param2/… 的形式[Contrôleur principal] 将使用配置文件将请求“路由”到正确的控制器。 为此,它将使用 URL 中的 [action] 字段。URL 和 [param1/param2/…] 的其余部分由可选参数组成,这些参数将传递给操作。 此处的MVC中的C即为字符串[Contrôleur principal, Contrôleur / Action]。如果没有任何控制器能处理所请求的操作,Web服务器将返回“未找到所请求的URL”的响应。
- 2 - 处理
- 所选操作 [2a] 可以利用 [Contrôleur principal] 传递给它的 parami 参数。这些参数可能来自两个来源:
- URL 中的 [/param1/param2/…] 路径,
- 客户端请求正文中提交的参数;
- 在处理用户请求时,该操作可能需要 [métier] 和 [2b] 层。一旦处理完客户端的请求,该操作可能会触发各种响应。一个典型的例子是:
- 若请求未能正确处理,则返回错误响应;
- 否则返回确认响应;
- [Contrôleur / Action] 将向主控制器返回其响应 [2c] 以及一个状态码。这些状态码将唯一地表示应用程序当前的状态。该状态码要么是成功码,要么是错误码;
- 3 - 响应
- 根据客户端请求的响应类型(jSON、 XML 或 HTML,[Contrôleur principal] 将实例化 [3a] 以生成相应的响应类型,并要求其将响应发送给客户端。 [Contrôleur principal] 将向其传递由已执行的 [Contrôleur / Action] 提供的响应和状态码;
- 如果所需的响应类型为 jSON 或 XML,则所选响应将对 [Contrôleur / Action] 提供的响应进行格式化,并通过 [3c] 发送出去。 能够处理此响应的客户端可以是 Python 控制台脚本,也可以是托管在 HTML 页面中的 JavaScript 脚本;
- 如果期望的响应类型为 HTML,则所选响应将根据给定的状态码,从 HTML 到 [Vuei] 的视图中选择 [3b]。 这就是 MVC 中的 V。一个状态码对应一个唯一的视图。该视图 V 将显示已执行的 [Contrôleur / Action] 的响应。 它使用 HTML、CSS 和 JavaScript 对该响应的数据进行格式化。这些数据被称为视图模型。这就是 MVC 中的 M。此时,客户端通常是一个浏览器;
32.2. HTML服务器脚本的树形结构

- 在 [1] 中,服务器 HTML 的静态元素;
- 在 [2-3] 中,服务器 HTML 的视图 V;片段 [2] 是视图 [3] 中可重用的元素;
- 在 [4] 中,一个用于静态测试视图的文件夹;
- 在 [5] 中,存放视图 V 的 M 模型文件夹,即 MVC 的 M 文件夹;
32.3. 视图介绍
Web 应用程序 HTML 使用四个视图。第一个视图是身份验证视图:
- 引导至该视图的操作是 [/init-session] [1];
- 点击按钮 [Valider] 将触发操作 [/authentifier-utilisateur],并提交两个参数 [2-3];
税款计算视图:

- 在 [1] 中,调用显示该视图的操作 [/authentifier-utilisateur];
- 在 [2] 中,点击按钮 [Valider] 将触发操作 [/calculer-impot],并传递三个参数 [2-5];
- 点击链接 [6] 将触发操作 [/lister-simulations](不带参数);
- 点击链接 [7] 将触发操作 [/fin-session],且不带参数;
第三个视图是经过身份验证的用户进行的模拟:

- 在 [1] 中,[/lister-simulations] 操作会显示此视图;
- 在 [2] 中,点击链接 [Supprimer] 将触发带有一个参数的操作 [/supprimer-simulation],该参数为列表中要删除的模拟编号;
- 点击链接 [3] 将触发操作 [/afficher-calcul-impot](无参数),重新显示税款计算视图;
- 点击链接 [4] 将触发无参数的操作 [/fin-session];
第四个视图将称为“意外错误视图”:
- 在 [1] 中:用户手动输入了 URL。但在本例中并未进行模拟。因此会收到错误信息 [2]。 我们已知该错误信息。此前在 jSON / XML 中也出现过。 我们将此类错误称为“意外错误”,因为它在应用程序的正常使用过程中不会发生。只有当用户手动输入 URL 时,才会出现此类错误;
- 若发生意外错误,可通过 [3-5] 链接返回其他三个视图之一;
回顾URL服务器的各项服务jSON / XML:
操作 | 角色 | 执行上下文 |
/init-session | 用于设定所需响应的类型(json、xml、html) | 请求 GET 可随时发出 |
/authentifier-utilisateur | 授权或拒绝用户登录 | 请求 POST。 该请求必须包含两个POST参数 [user, password] 仅当会话类型(json、xml、html)已知时才可发出 |
/计算-税款 | 进行税款计算模拟 | 请求 POST。 该请求必须包含三个POST参数:[marié, enfants, salaire] 仅当会话类型(json、xml、html)已知且用户已通过身份验证时才可发出 |
/lister-simulations | 请求查看自会话开始以来执行的模拟列表 | 请求 GET。 仅当会话类型(json、xml、html)已知且用户已通过身份验证时,方可发出 |
/删除-模拟/编号 | 从模拟列表中删除一个模拟 | 请求 GET。 仅当会话类型(json、xml、html)已知且用户已通过身份验证时,方可发出 |
/显示-税款计算 | 显示税款计算页面 HTML | 请求 GET。 仅当会话类型(json、xml、html)已知且用户已通过身份验证时才可发出 |
/结束-会话 | 结束模拟会话。 | 技术上,旧的 Web 会话将被删除,并创建一个新的会话 仅当会话类型(json、xml、html)已知且用户已通过身份验证时才可发出 |
这些不同的 URL 服务也将用于 HTML 服务器。
32.4. 视图配置
一个操作由控制器处理。该控制器返回一个元组 (结果, status_code),其中:
- [résultat] 是键值对字典 [action, état, réponse];
- [status_code] 是将返回给客户端的响应 HTTP 的状态码;
在 HTML 会话中,执行某项操作后显示的页面取决于控制器返回的状态码。这种依赖关系在 [config] 配置中体现如下:
# 视图 HTML 及其模板取决于控制器返回的状态
"views": [
{
# 身份验证视图
"états": [
# /init-session 成功
700,
# /用户认证失败
201
],
"view_name": "views/vue-authentification.html",
"model_for_view": ModelForAuthentificationView()
},
{
# 税款计算视图
"états": [
# /用户认证成功
200,
# /计算-税款 成功
300,
# /计算-税款 失败
301,
# /显示-税款计算
800
],
"view_name": "views/vue-calcul-impot.html",
"model_for_view": ModelForCalculImpotView()
},
{
# 模拟列表视图
"états": [
# /列出模拟
500,
# /删除-模拟
600
],
"view_name": "views/vue-liste-simulations.html",
"model_for_view": ModelForListeSimulationsView()
}
],
# 意外错误视图
"view-erreurs": {
"view_name": "views/vue-erreurs.html",
"model_for_view": ModelForErreursView()
},
# 重定向
"redirections": [
{
"états": [
400, # /会话结束成功
],
# 重定向至
"to": "/init-session/html",
}
],
}
- 第 2-40 行:[views] 是一个视图列表。我们来分析第 3-13 行的视图:
- 第 11 行:显示的视图 V;
- 第 12 行:负责生成该视图模型 M 的类实例;
- 第 5-10 行:通向该视图的状态;
- 第 3-13 行:身份验证视图;
- 第 14-28 行:税款计算视图;
- 第29-39行:模拟列表视图;
- 第42-46行:意外错误视图;
- 第49-57行:某些报表会通过重定向跳转到某个视图。例如报表400,即操作[/fin-session]成功后的情况。此时需将客户端重定向至操作[http://machine:port/chemin/init-session/html];
接下来我们将介绍不同的视图。
32.5. 身份验证视图

32.5.1. 视图介绍
身份验证视图如下:

该视图由两个元素组成,我们称之为片段:
- 片段 [1] 由片段 [v-bandeau.html] 生成;
- 片段 [2] 由片段 [v-authentification.html] 生成;
该身份验证视图由以下页面 [vue-authentification.html] 生成:
<!-- 文档 HTML -->
<!doctype html>
<html lang="fr">
<head>
<!-- 必需的元标签 -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- BootstrapCSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css">
<title>Application impôts</title>
</head>
<body>
<div class="container">
<!-- 横幅 -->
{% include "fragments/v-bandeau.html" %}
<!-- 双栏布局 -->
<div class="row">
<div class="col-md-9">
{% include "fragments/v-authentification.html" %}
</div>
</div>
<!-- 如果出现错误,则显示错误提示 -->
{% if modèle.error %}
<div class="row">
<div class="col-md-9">
<div class="alert alert-danger" role="alert">
Les erreurs suivantes se sont produites :
<ul>{{modèle.erreurs|safe}}</ul>
</div>
</div>
</div>
{% endif %}
</div>
</body>
</html>
注释
- 第 2 行:HTML 文档以此行开头;
- 第 3-36 行:HTML 页面被封装在 <html> </html> 标签中;
- 第4-11行:HTML文档的头部(head);
- 第 6 行:此处的 <meta charset> 标签表明该文档采用 UTF-8 编码;
- 第7行:<meta name='viewport'>标签设置了视口的初始显示方式:占据显示屏幕的整个宽度(width),保持原始比例(initial-scale),且不缩放以适应较小的屏幕尺寸(shrink-to-fit);
- 第 9 行:<link rel='stylesheet'> 标签指定了控制视图外观的 CSS 文件。此处我们使用的是 CSS Bootstrap 4.4.1 [https://getbootstrap.com/docs/4.0/getting-started/introduction/] 框架 ;
- 第 10 行:<title> 标签设定了页面的标题:

- 第 13-35 行:网页主体被封装在 <body> 和 </body> 标签中;
- 第 14-34 行:<div> 标签界定了页面显示的一个部分。 视图中使用的 [class] 属性均引用了 CSS Bootstrap 框架。<div class=’container’> 标签(第 14 行)界定了 Bootstrap 容器;
- 第 26 行:引入了 [v-bandeau.html] 片段。该片段生成页面的 [1] 横幅。我们稍后将对此进行说明;
- 第 18-22 行:<div class=’row’> 标签界定了 Bootstrap 的一行。这些行由 12 列组成;
- 第 19 行:<div class=’col-md-9’> 标签界定了一个 9 列的区域;
- 第20行:引入了[v-authentification.html]片段,该片段用于显示页面中的[2]身份验证表单。我们稍后将对此进行说明;
- 第 24-33 行:这些行中的代码 HTML 仅在 [modèle.error] 值为 True 时才会被使用。 我们将始终遵循以下流程:HTML视图的模型将被封装在[modèle]字典中;
- 第24-33行:若用户输入的凭据不正确,认证将失败。此时,认证视图将重新显示并附带一条错误信息。属性[modèle.error]用于指示是否显示该错误信息;
- 第27-30行:划定一个粉色背景区域(class="alert alert-danger")(第27行);

- 第 28 行:一段文本;
- 第 29 行:标签 HTML <ul>(无序列表)用于显示带项目符号的列表。列表中的每个元素必须采用 <li>元素</li> 的语法。此处显示的是 [modèle.erreurs] 的值。 该值由过滤器 [safe] 进行过滤(检测是否包含 |)。默认情况下,当需要向浏览器发送字符串时,Flask 会“中和”其中可能存在的所有 HTML 标签,以防止浏览器对其进行解析。 但有时我们需要让浏览器进行解析。例如,当字符串 [modèle.erreurs] 包含用于界定列表项的 HTML 标签 <li> 和 </li> 时,就属于这种情况。 此时,我们使用过滤器 [safe],它会告知 Flask 该显示的字符串是安全的(safe),因此它不应屏蔽其中发现的 HTML 标签;
请记住此代码中需要定义的动态元素:
- [modèle.error]:用于显示错误消息;
- [modèle.erreurs]:一个错误消息列表(按HTML中的定义);
32.5.2. 片段 [v-bandeau.html]
片段 [v-bandeau.html] 用于显示 Web 应用程序所有视图的顶部横幅:

片段 [v-bandeau.html] 的代码如下:
<!-- Bootstrap Jumbotron -->
<div class="jumbotron">
<div class="row">
<div class="col-md-4">
<img src="{{ url_for('static', filename='images/logo.jpg') }}" alt="Cerisier en fleurs"/>
</div>
<div class="col-md-8">
<h1>
Calculez votre impôt
</h1>
</div>
</div>
</div>
注释
- 第 2-13 行:横幅被封装在一个名为 Jumbotron 的 Bootstrap 部分中([<div class="jumbotron">])。该 Bootstrap 类通过特殊样式处理显示内容,使其更加醒目;
- 第3-12行:一行Bootstrap代码;
- 第4-6行:一张[img]图片被放置在该行的前四列中;
- 第 5 行:语法:
调用了 Flask 的 [url_for] 函数。此处的参数值为 [static] 文件夹中 [images/logo.pg] 文件的 URL;
- 第7-11行:该行的其余8列(需注意总共12列)将用于放置一段文本(第9行),并以大号字体显示(<h1>,第8-10行);
32.5.3. [v-authentification.html]片段
片段 [v-authentification.html] 显示了 Web 应用程序的身份验证表单:

片段 [v-authentification.html] 的代码如下:
<!-- 表单 HTML - 使用操作 [authentifier-utilisateur] 提交其值 -->
<form method="post" action="/authentifier-utilisateur">
<!-- 标题 -->
<div class="alert alert-primary" role="alert">
<h4>Veuillez vous authentifier</h4>
</div>
<!-- Bootstrap 表单 -->
<fieldset class="form-group">
<!-- 第一行 -->
<div class="form-group row">
<!-- 标签 -->
<label for="user" class="col-md-3 col-form-label">Nom d'utilisateur</label>
<div class="col-md-4">
<!-- 文本输入框 -->
<input type="text" class="form-control" id="user" name="user"
placeholder="Nom d'utilisateur" value="{{ modèle.login }}" required>
</div>
</div>
<!-- 第二行 -->
<div class="form-group row">
<!-- 标签 -->
<label for="password" class="col-md-3 col-form-label">Mot de passe</label>
<!-- 文本输入框 -->
<div class="col-md-4">
<input type="password" class="form-control" id="password" name="password"
placeholder="Mot de passe" required>
</div>
</div>
<!-- 第三行上的 [submit] 类型按钮 -->
<div class="form-group row">
<div class="col-md-2">
<button type="submit" class="btn btn-primary">Valider</button>
</div>
</div>
</fieldset>
</form>
注释
- 第 2-39 行:<form> 标签界定了 HTML 表单。该表单通常具有以下特征:
- 它定义了输入区域(第 17 行和第 27 行的 <input> 标签);
- 它有一个类型为[submit]的按钮(第34行),该按钮会将输入的值发送至[form]标签的[action]属性中指定的URL (第2行)中指定的URL。用于调用该URL的方法HTTP,在[form]标签(第2行)的[method]属性中进行了指定;
- 在此,当用户点击 [Valider] 按钮(第 34 行)时,浏览器将向 URL [/authentifier-utilisateur](第 2 行)提交(第 2 行)表单中输入的值;
- 提交的值即用户在第17行和第27行输入框中输入的值。这些值将作为请求主体,由浏览器以[x-www-forl-urlencoded]的形式发送至HTTP。 [user, password] 参数的名称即为第 17 行和第 27 行输入框中 [name] 属性的名称;
- 第5-7行:一个Bootstrap板块,用于在蓝色背景上显示标题:
- 第10-37行:一个Bootstrap表单。表单中的所有元素都将按特定样式进行设计;
- 第12-20行:定义表单的第一行Bootstrap样式:
![]()
- 第 14 行将标签 [1] 定义为三列布局。 [label] 标签的 [for] 属性将该标签与第 17 行输入框的 [id] 属性关联起来;
- 第 15-19 行:将输入框设置为四列布局;
- 第17-18行:标签HTML [input]描述了一个输入框。它有多个参数:
- [type=’text’]:这是一个文本输入框。可以在其中输入任意内容;
- [class=’form-control’]:输入框的 Bootstrap 样式;
- [id=’user’]:输入框的标识符。该标识符通常由 CSS 和 JavaScript 代码使用;
- [name=’user’]:输入框的名称。浏览器将通过此名称提交用户输入的值;
- [placeholder=’invite’]:当用户尚未输入任何内容时,输入框中显示的文本;
![]()
- (续)
- [value=’valeur’]:输入框一经显示,即会显示文本“值”,即在用户输入其他内容之前。此机制用于在发生错误时显示导致错误的输入内容。 此处的值即为变量 [modèle.login] 的值;
- [required]:要求用户输入值,表单才能发送至服务器:
- 第21-30行:用于密码输入的类似代码;
- 第27行:[type=’password’] 生成一个文本输入框(可输入任意内容),但输入的字符会被隐藏:
![]()
- 第 32-36 行:用于按钮 [Valider] 的第三行 Bootstrap 代码;
- 第 34 行:由于该按钮具有 [type=submit] 属性,点击此按钮会触发浏览器将输入的值发送至服务器,具体原理如前所述。 CSS [class="btn btn-primary"] 属性会显示一个蓝色按钮:
最后还有一点需要说明。第 2 行,属性 [action="/authentifier-utilisateur"] 定义了一个不完整的 URL(它不以 http://machine:port/chemin 开头)。 在本例中,应用程序中的所有 URL 均采用 [http://machine:port/chemin/action/param1/param2/..] 的形式,其中 [http://machine:port/chemin] 是服务 URL 的根节点。 在 [action="/authentifier-utilisateur"] 中,我们有一个绝对的 URL,即从 URL 的根节点开始计算。 因此,POST 的完整 URL 即为 [http://machine:port/chemin/authentifier-utilisateur],这也是浏览器将使用的版本。
需要注意的是,该片段使用的是 [modèle.login] 模板。
32.5.4. 视觉测试
在视图集成到应用程序之前,就可以对其进行测试。这里主要测试的是视图的视觉效果。我们将把所有测试视图都放在项目中的 [tests_views] 文件夹中:

要测试视图 V [vue-authentification.html],我们需要创建它将要显示的数据模型 M。我们使用脚本 [test_vue_authentification.py] 来完成此操作:
from flask import Flask, render_template, make_response
# Flask 应用程序
app = Flask(__name__, template_folder="../templates", static_folder="../static")
# 首页 URL
@app.route('/')
def index():
# 将页面数据封装到模板中
modèle = {}
# 用户 ID
modèle["login"] = "albert"
# 错误列表
modèle["error"] = True
erreurs = ["erreur1", "erreur2"]
# 构建错误列表 HTML
content = ""
for erreur in erreurs:
content += f"<li>{erreur}</li>"
modèle["erreurs"] = content
# 显示页面
return make_response(render_template("views/vue-authentification.html", modèle=modèle))
# 主函数
if __name__ == '__main__':
app.config.update(ENV="development", DEBUG=True)
app.run()
注释
- 第 1-3 行:创建一个 Flask 应用程序,其唯一目的是显示视图 [vue-authentification.html](第 22 行);
- 第 7 行:该应用程序仅有一个 URL 服务视图;
- 第9-20行:身份验证视图包含由[modèle]对象控制的动态部分。 该对象被称为视图模型。根据MVC这一缩写所给出的两个定义之一,这里对应的是MVC中的M。在定义[vue-authentification.html]视图时,我们已确定了三个动态值:
- [modèle.error]:布尔值,用于指示是否显示错误消息;
- [modèle.erreurs]:包含错误消息的列表 HTML;
- [modèle.login]:用户的登录名;
因此,我们需要定义这三个动态值。
- 第 9-20 行:定义身份验证视图中的三个动态元素;
进行测试时,运行脚本 [tests_views/test_vue_authentification.py] 并请求 URL [/localhost:5000/]:
继续进行这些可视化测试,直到对结果满意为止。

32.5.5. 视图模型的计算
确定视图的视觉效果后,即可在实际条件下计算视图模型。视图模型将由[models_for_views]文件夹中的类生成:

每个生成视图模型的类都将遵循以下 [InterfaceModelForView] 接口:
from abc import ABC, abstractmethod
from flask import Request
from werkzeug.local import LocalProxy
class InterfaceModelForView(ABC):
@abstractmethod
def get_model_for_view(self, request: Request, session: LocalProxy, config: dict, résultat: dict) -> dict:
pass
- 第 8-10 行:方法 [get_model_for_view] 负责生成一个封装在字典中的视图模板。为此,它接收以下信息:
- [request, session, config] 是操作控制器所使用的相同参数。因此,这些参数也会传递给视图模型;
- 控制器生成了一个结果 [résultat],该结果同样被传递给视图模型。该结果包含一个重要元素 [état],用于指示当前操作的执行情况。视图模型将利用此信息;
我们看到,在应用程序的 [config] 配置中,控制器返回的状态码被用于指定要显示的视图 HTML:
# 视图 HTML 及其模板取决于控制器返回的状态
"views": [
{
# 身份验证视图
"états": [
# /init-session 成功
700,
# /用户认证失败
201
],
"view_name": "views/vue-authentification.html",
"model_for_view": ModelForAuthentificationView()
},
{
# 税款计算视图
"états": [
# /用户认证成功
200,
# /计算-税款 成功
300,
# /计算-税款 失败
301,
# /显示-税款计算
800
],
"view_name": "views/vue-calcul-impot.html",
"model_for_view": ModelForCalculImpotView()
},
{
# 模拟列表视图
"états": [
# /列出模拟
500,
# /删除-模拟
600
],
"view_name": "views/vue-liste-simulations.html",
"model_for_view": ModelForListeSimulationsView()
}
],
# 意外错误视图
"view-erreurs": {
"view_name": "views/vue-erreurs.html",
"model_for_view": ModelForErreursView()
},
# 重定向
"redirections": [
{
"états": [
400, # /会话结束成功
],
# 重定向至
"to": "/init-session/html",
}
],
}
因此,正是状态代码 [700, 201](第 7 行和第 9 行)触发了身份验证视图的显示。 要了解这些代码的含义,可以参考在应用程序 jSON 上执行的 [Postman] 测试:
- [init-session-json-700]:700是[init-session]操作成功后的状态码:此时将显示空白的身份验证表单;
- [authentifier-utilisateur-201]:201是[authentifier-utilisateur]操作失败(凭证不被识别)后的状态码:此时将显示身份验证表单以便用户更正;
既然我们已经知道在何时应显示身份验证表单,就可以在 [ModelForAuthentificationView](第 12 行)中计算其模板:
from flask import Request
from werkzeug.local import LocalProxy
from InterfaceModelForView import InterfaceModelForView
class ModelForAuthentificationView(InterfaceModelForView):
def get_model_for_view(self, request: Request, session: LocalProxy, config: dict, résultat: dict) -> dict:
# 将页面数据封装到模板中
modèle = {}
# 应用程序状态
état = résultat["état"]
# 模板取决于状态
if état == 700:
# 显示空表单的情况
modèle["login"] = ""
# 无错误需显示
modèle["error"] = False
elif état == 201:
# 身份验证错误
# 重新显示最初输入的用户
modèle["login"] = request.form.get("user")
# 有错误需要显示
modèle["error"] = True
# 错误消息列表 HTML
erreurs = ""
for erreur in résultat["réponse"]:
erreurs += f"<li>{erreur}</li>"
modèle["erreurs"] = erreurs
# 渲染模板
return modèle
注释
- 第 8 行:身份验证视图中的 [get_model_for_view] 方法需提供一个包含三个键 [error, erreurs, login] 的字典。该计算基于操作控制器返回的状态码进行;
- 第 12 行:获取处理当前操作的控制器返回的状态码;
- 第 14-29 行:模型依赖于该状态码;
- 第15-18行:需显示空白身份验证表单的情况;
- 第20-29行:认证失败的情况:显示用户输入的用户名并显示一条错误信息。此时用户可以通过键盘重新尝试认证;
- 第22行:用户最初输入的用户名可在客户端请求中获取;
- 第24行:提示存在需要显示的错误;
- 第26-29行:若出现错误,结果[‘réponse’]中包含错误列表;
32.5.6. 生成响应 HTML
让我们回到应用程序 HTML 中的模型 MVC:
- 步骤 2(2a、2b):控制器执行一个操作;
- 在 3 (3a, 3b, 3c):选择一个视图并将其发送给客户端;
在 [3a] 中,选择了一种响应类型(jSON、XML、HTML)。 我们已经了解了 jSON 和 XML 响应的生成方式,但尚未涉及 HTML 响应。这些响应由类 [HtmlResponse] 生成:

回顾一下在主脚本 [main] 中,如何确定向用户返回的响应类型:
….
# 正在构建待发送的响应
response_builder = config["responses"][type_response]
response, status_code = response_builder \
.build_http_response(request, session, config, status_code, résultat)
# 发送响应
return response, status_code
其中,第 3 行中的 config[‘responses’] 是以下字典:
# 各种响应类型(json、xml、html)
"responses": {
"json": JsonResponse(),
"html": HtmlResponse(),
"xml": XmlResponse()
},
因此,[HtmlResponse] 类负责生成 HTML 响应。其代码如下:
# 根据结果中包含的状态生成的响应字典 HTML
from flask import make_response, render_template
from flask.wrappers import Response
from werkzeug.local import LocalProxy
from InterfaceResponse import InterfaceResponse
class HtmlResponse(InterfaceResponse):
def build_http_response(self, request: LocalProxy, session: LocalProxy, config: dict, status_code: int,
résultat: dict) -> (Response, int):
# 响应 HTML 取决于控制器返回的状态码
état = résultat["état"]
# 是否需要进行重定向?
for redirection in config["redirections"]:
# 需要重定向的状态
états = redirection["états"]
if état in états:
# 需要进行重定向
return redirect(f"/{redirection['to']}"), status.HTTP_302_FOUND
# 一个状态对应一个视图
# 在视图列表中查找该视图
views_configs = config["views"]
trouvé = False
i = 0
# 遍历视图列表
nb_views = len(views_configs)
while not trouvé and i < nb_views:
# 视图编号 i
view_config = views_configs[i]
# 与视图编号 i 关联的报表
états = view_config["états"]
# 所查找的报表是否位于视图编号 i 的关联报表中
if état in états:
trouvé = True
else:
# 下一视图
i += 1
# 找到?
if not trouvé:
# 如果应用程序当前状态下不存在任何视图
# 渲染错误视图
view_config = config["view-erreurs"]
# 计算待显示视图的模板
model_for_view = view_config["model_for_view"]
modèle = model_for_view.get_model_for_view(request, session, config, résultat)
# 生成响应代码
html = render_template(view_config["view_name"], modèle=modèle)
# 构建响应 HTTP
response = make_response(html)
response.headers['Content-Type'] = 'text/html; charset=utf-8'
# 返回结果
return response, status_code
- 第 11 行:负责生成响应 HTML 的方法 [build_http_response] 接收以下参数:
- [request, session, dict]:这是控制器用于处理当前操作的参数;
- [status_code, résultat]:是该控制器生成的两个结果;
- 第 14 行:如前所述,服务器的响应 HTML 取决于字典 [résultat] 中包含的状态码;
- 第16-22行:首先处理重定向。目前我们将忽略此情况,直到遇到重定向示例时再作处理。需要注意的是,重定向通常是服务器HTML的一个典型用例。 在服务器 jSON 和 ouXML 上不会遇到这种情况;
- 第24-41行:在视图中搜索其[états]列表中包含所需状态的视图;
- 第42-46行:若未找到任何视图,则视为意外错误。让我们举个例子。在应用程序的正常运行中,操作[/supprimer-simulation]绝不会出错。 事实上,我们将看到,这种模拟操作的删除是通过代码生成的链接来实现的。这些链接是正确的,不会导致错误。然而,正如我们所见,用户可以直接输入 URL 或 [/supprimer-simulation/id],从而引发错误。 在此情况下,控制器 [SupprimerSimulationController] 会返回状态码 601。但该状态码并不在导致显示页面 HTML 的状态码列表中。 因此将显示错误视图。该视图在配置中定义如下:
# 显示意外错误
"view-erreurs": {
"view_name": "views/vue-erreurs.html",
"model_for_view": ModelForErreursView()
},
- 第49行:一旦确定了要显示的视图,就获取生成其模型的类。该类同样位于配置文件[config]中;
- 第 50 行:找到该类后,生成视图的模型;
- 第 52 行:计算出视图 V 的模型 M 后,即可生成视图的代码 HTML;
- 第54-55行:使用正文HTML构建响应HTTP;
- 第56-57行:返回响应HTTP及其状态码;
32.5.7. [Postman] 测试
我们将执行生成状态码 [700, 201] 的请求,这些请求会显示身份验证视图:
- [init-session-html-700]:700是[init-session]操作成功后的状态码:此时将显示空的身份验证表单;
- [authentifier-utilisateur-201]:201是[authentifier-utilisateur]操作失败(凭证不被识别)后的状态码:此时将显示认证表单以便用户更正;
只需重新调用这些代码,并检查是否正确显示了认证视图。此处展示两种情况:
情况 1:[init-session-html-700],HTML 会话的开始;

响应如下:

- 在 [5] 中,通过 [Preview] 模式可查看收到的 HTML 页面;
- 在 [6] 中,确实看到了预期的空表单;
- 在 [7] 中,Postman 未点击页面中的图片链接;
- 在 [8] 中,[Raw] 模式可访问收到的 HTML;

- 在 [3] 中,Postman 未加载的链接。它显示了 [alt=alternative] 属性的值,该值会在图片无法加载时显示。 这里的情况更像是 Postman 故意不加载该图片。可以通过使用 Postman 请求 URL 和 [http://localhost :5000/static/images.logo.jpg] 来验证:
情况 2:[authentifier-utilisateur-201],身份验证错误

现在,在成功初始化 HTML 会话后,我们尝试进行一次身份验证错误:

上文:
- 在 [4,7] 中:请求发送了字符串 [user=bernard&password=thibault];
响应如下:

- 在 [4] 中,显示一条错误消息;
- 在 [3] 中,错误的用户信息再次显示;
32.5.8. 结论
我们在未编写其他视图的情况下成功测试了视图 [vue-authentification.html]。这是因为:
- 所有控制器均已编写;
- [Postman] 允许我们在无需所有视图的情况下向服务器发送请求。编写控制器时,必须做好处理任何视图都无法处理的请求的准备。绝不能先入为主地认为“这个请求是不可能的”,必须进行验证;
32.6. 税额计算视图

32.6.1. 视图概述
税款计算视图如下:

该视图分为三个部分:
- 1:顶部横幅由前文已介绍的片段 [v-bandeau.html] 生成;
- 2:由片段 [v-calcul-impot.html] 生成的税款计算表单;
- 3:包含两个链接的菜单,由片段 [v-menu.html] 生成;
税款计算视图由以下代码 [vue-calcul-impot.html] 生成:
<!-- 文档 HTML -->
<!doctype html>
<html lang="fr">
<head>
<!-- 必需的元标签 -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- BootstrapCSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css">
<title>Application impôts</title>
</head>
<body>
<div class="container">
<!-- 横幅 -->
{% include "fragments/v-bandeau.html" %}
<!-- 双栏布局 -->
<div class="row">
<!-- 菜单 -->
<div class="col-md-3">
{% include "fragments/v-menu.html" %}
</div>
<!-- 计算表单 -->
<div class="col-md-9">
{% include "fragments/v-calcul-impot.html" %}
</div>
</div>
<!-- 成功情况 -->
{% if modèle.success %}
<!-- 显示成功提示 -->
<div class="row">
<div class="col-md-3">
</div>
<div class="col-md-9">
<div class="alert alert-success" role="alert">
{{modèle.impôt}}</br>
{{modèle.décôte}}</br>
{{modèle.réduction}}</br>
{{modèle.surcôte}}</br>
{{modèle.taux}}</br>
</div>
</div>
</div>
{% endif %}
{% if modèle.error %}
<!-- 9 列错误列表 -->
<div class="row">
<div class="col-md-3">
</div>
<div class="col-md-9">
<div class="alert alert-danger" role="alert">
Les erreurs suivantes se sont produites :
<ul>{{modèle.erreurs | safe}}</ul>
</div>
</div>
</div>
{% endif %}
</div>
</body>
</html>
注释
- 我们仅对尚未出现的新内容进行注释;
- 第 16 行:将视图的顶部横幅包含在视图的第一行 Bootstrap 中;
- 第 21 行:将菜单嵌入视图的第二行 Bootstrap 中,该菜单将占据三列(第 18、20 行);
- 第25行:插入税额计算表单,该表单将占据视图第二行Bootstrap布局中的九列(第24行);
- 第30-46行:如果税额计算成功([modèle.success=True]),则将计算结果显示在绿色框中(第37-43行)。 该框位于视图的第三行 Bootstrap(第 32 行),占据三个空列(第 33-35 行)右侧的九个列(第 36 行)。因此,该框将位于税额计算表下方;
- 第48-61行:如果税额计算失败([modèle.error=True]),则会在一个粉色框中显示一条错误消息(第55-58行)。 该框位于视图的第三行 Bootstrap 区域(第 50 行),占据九列(第 54 行),位于三个空列(第 51-53 行)的右侧。因此,该框也将位于税款计算表单下方;
32.6.2. 片段 [v-calcul-impot.html]
片段 [v-calcul-impot.html] 显示了 Web 应用程序中的税款计算表单:
片段 [v-calcul-impot.html] 的代码如下:

<!-- 已提交的表单 HTML -->
<form method="post" action="/calculer-impot">
<!-- 12列蓝色背景消息 -->
<div class="col-md-12">
<div class="alert alert-primary" role="alert">
<h4>Remplissez le formulaire ci-dessous puis validez-le</h4>
</div>
</div>
<!-- 表单元素 -->
<fieldset class="form-group">
<!-- 9 列第一行 -->
<div class="row">
<!-- 4 列的标签 -->
<legend class="col-form-label col-md-4 pt-0">Etes-vous marié(e) ou pacsé(e)?</legend>
<!-- 5列单选按钮-->
<div class="col-md-5">
<div class="form-check">
<input class="form-check-input" type="radio" name="marié" id="gridRadios1" value="oui" {{modèle.checkedOui}}>
<label class="form-check-label" for="gridRadios1">
Oui
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="marié" id="gridRadios2" value="non" {{modèle.checkedNon}}>
<label class="form-check-label" for="gridRadios2">
Non
</label>
</div>
</div>
</div>
<!-- 9列中的第二行 -->
<div class="form-group row">
<!-- 4列标签 -->
<label for="enfants" class="col-md-4 col-form-label">Nombre d'enfants à charge</label>
<!-- 5列的子女数量数字输入区 -->
<div class="col-md-5">
<input type="number" min="0" step="1" class="form-control" id="enfants" name="enfants" placeholder="Nombre d'enfants à charge" value="{{modèle.enfants}}" required>
</div>
</div>
<!-- 9列中的第3行 -->
<div class="form-group row">
<!-- 4列标题 -->
<label for="salaire" class="col-md-4 col-form-label">Salaire annuel net imposable</label>
<!-- 5列的工资数值输入区 -->
<div class="col-md-5">
<input type="number" min="0" step="1" class="form-control" id="salaire" name="salaire" placeholder="Salaire annuel net imposable" aria-describedby="salaireHelp" value="{{modèle.salaire}}" required>
<small id="salaireHelp" class="form-text text-muted">Arrondissez à l'euro inférieur</small>
</div>
</div>
<!-- 第四行,5列的[submit]按钮 -->
<div class="form-group row">
<div class="col-md-5">
<button type="submit" class="btn btn-primary">Valider</button>
</div>
</div>
</fieldset>
</form>
注释
- 第 2 行:表单 HTML 将被提交(属性 [method])至 URL [/calculer-impot] (属性 [action])。提交的值将是输入字段的值:
- 已选中单选按钮的值,格式为:
- 若单选按钮 [Oui] 被选中,则为 [marié=oui](第 17-22 行)。 [marié] 是第 18 行中 [name] 属性的值,[oui] 是第 18 行中 [value] 属性的值;
- 若单选按钮 [Non] 被选中,则 [marié=non] 即为该值(第 23-28 行)。 [marié] 是第 24 行 [name] 属性的值,[non] 是第 24 行 [value] 属性的值;
- 第37行数字输入框的值为[enfants=xx],其中[enfants]是第37行[name]属性的值, 而 [xx] 则是用户通过键盘输入的值;
- 第46行数字输入框的值为[salaire=xx],其中[salaire]是第46行[name]属性的值, 而 [xx] 则是用户通过键盘输入的值;
最终,提交的值将呈现为 [marié=xx&enfants=yy&salaire=zz]。
- (续)
- 当用户点击第 53 行中类型为 [submit] 的按钮时,输入的值将被提交;
- 第16-30行:两个单选按钮:
![]()
这两个单选按钮属于同一组,因为它们具有相同的 [name] 属性(第 18、24 行)。浏览器会确保在单选按钮组中,任何时刻仅有一个被选中。因此,点击其中一个会取消之前被选中的那个;
- 它们之所以是单选按钮,是因为具有 [type="radio"] 属性(第 18、24 行);
- 在表单显示时(输入前),其中一个单选按钮必须处于选中状态:只需在相应的 <input type="radio"> 标签中添加 [checked=’checked’] 属性即可。这通过动态变量实现:
- 第18行添加[modèle.checkedOui];
- 第 24 行:[modèle->checkedNon];
这些变量将作为视图模板的一部分。
- 第 37 行:一个数字输入框 [type="number"],其最小值为 0 [min="0"]。 在现代浏览器中,这意味着用户只能输入大于等于0的数值。在这些现代浏览器中,用户可以通过点击滑块向上或向下调整数值。 第 37 行中的 [step="1"] 属性表明,该滑块将以 1 为增量进行操作。这意味着滑块仅接受从 0 到 n 之间、步长为 1 的整数值。对于手动输入而言,这意味着不接受带小数的数字;
- 第37行:在某些显示场景下,子节点的输入框需预先填入该区域的上次输入值。为此使用属性[value]来设定输入框中显示的值。该值将由变量[modèle.enfants]动态生成;
- 第37行:属性[required]强制要求用户输入数据,表单才能通过验证;
- 第46行:关于工资输入的说明与子女信息输入的说明相同;
- 第53行:类型为[submit]的按钮,该按钮会触发POST,并将输入的值传递给URL和[/calculer-impot](第2行);
![]()
32.6.3. 片段 [v-menu.html]
该片段在税款计算表单的左侧显示一个菜单:

该片段的代码如下:
<!-- Bootstrap 菜单 -->
<nav class="nav flex-column">
<!-- 显示链接列表HTML -->
{% for optionMenu in modèle.optionsMenu %}
<a class="nav-link" href="{{optionMenu.url}}">{{optionMenu.text}}</a>
{% endfor %}
</nav>
注释
- 第2-7行:标签 HTML [nav] 包围了一段包含指向其他文档导航链接的文档 HTML;
- 第 5 行:标签 HTML [a] 引入了一个导航链接:
- [optionMenu.url]:是点击链接 [optionMenu.text] 时跳转到的目标 URL。此时,浏览器执行的是一项 [GET optionMenu.url] 操作。 [optionMenu.url] 将是相对于应用程序根目录 [http://machine :port/chemin] 测量的绝对路径 URL。因此,在 [1] 中,我们将创建以下链接:
- 第 5 行:片段的 [modèle.optionsMenu] 模板将是一个如下形式的列表:
- 第2、7行:类CSS和[nav, flex-column, nav-link]是Bootstrap类,用于定义菜单的外观;
32.6.4. 视觉测试
我们将这些不同元素整合到 [Tests] 文件夹中,并为视图 [vue-calcul-impot.html] 创建了一个测试模板:

测试脚本 [test_vue_calcul_impot] 如下:
from flask import Flask, render_template, make_response
# Flask 应用程序
app = Flask(__name__, template_folder="../templates", static_folder="../static")
# 首页URL
@app.route('/')
def index():
# 将页面数据封装到模板中
modèle = {}
# 表单
modèle["checkedOui"] = ""
modèle["checkedNon"] = 'checked="checked"'
modèle["enfants"] = 2
modèle["salaire"] = 300000
# 成功消息
modèle["success"] = True
modèle["impôt"] = "Montant de l'impôt : 1000 euros"
modèle["décôte"] = "Décôte : 15 euros"
modèle["réduction"] = "Réduction : 20 euros"
modèle["surcôte"] = "Surcôte : 0 euros"
modèle["taux"] = "Taux d'imposition : 14 %"
# 错误消息
modèle["error"] = True
erreurs = ["erreur1", "erreur2"]
# 构建错误列表 HTML
content = ""
for erreur in erreurs:
content += f"<li>{erreur}</li>"
modèle["erreurs"] = content
# 菜单
modèle["optionsMenu"] = [
{"text": 'Liste des simulations', "url": '/lister-simulations'},
{"text": 'Fin de session', "url": '/fin-session'}]
# 显示页面
return make_response(render_template("views/vue-calcul-impot.html", modèle=modèle))
# 主菜单
if __name__ == '__main__':
app.config.update(ENV="development", DEBUG=True)
app.run()
注释
- 第 9-34 行:初始化视图 [vue-calcul-impot.html] 以及片段 [v-calcul-impot.html] 和 [v-menu.html] 的所有动态部分;
- 第 36 行:显示视图 [vue-calcul-impot.html];
执行测试脚本 [test_vue_calcul_impot] 时,将得到以下结果:
对该视图进行调整,直到视觉效果令人满意。随后即可将其集成到正在开发的 Web 应用程序中。

32.6.5. 视图模型的计算
确定视图的视觉效果后,即可在实际条件下计算视图模型。回顾一下通向该视图的状态代码。这些代码位于配置文件中:
{
# 税款计算视图
"états": [
# /用户认证成功
200,
# /计算-税款 成功
300,
# /计算-税款 失败
301,
# /显示-税款计算
800
],
"view_name": "views/vue-calcul-impot.html",
"model_for_view": ModelForCalculImpotView()
},
因此,正是状态代码 [200, 300, 301, 800] 触发了税款计算视图的显示。要了解这些代码的含义,可以参考在应用程序 jSON 上执行的测试 [Postman]:
- [authentifier-utilisateur-200]:200是[authentifier-utilisateur]操作成功后的状态代码:此时将显示空白的税款计算表单;
- [calculer-impot-300]:300是[calculer-impot]操作成功后的状态码。此时将显示包含已输入数据及税额的计算表单。用户随后可重新进行计算;
- 状态代码 [301] 表示税款计算有误;
- 状态代码 [800] 将在后续介绍。我们目前尚未遇到该代码;
既然我们已经知道在何时应显示税额计算表,就可以在类 [ModelForCalculImpotView] 中设计其模板:

from flask import Request
from werkzeug.local import LocalProxy
from InterfaceModelForView import InterfaceModelForView
class ModelForCalculImpotView(InterfaceModelForView):
def get_model_for_view(self, request: Request, session: LocalProxy, config: dict, résultat: dict) -> dict:
# 将视图数据封装到模板中
modèle = {}
# 应用程序状态
état = résultat["état"]
# 模型取决于状态
if état in [200, 800]:
# 初始显示一个空表单
modèle["success"] = False
modèle["error"] = False
modèle["checkedNon"] = 'checked="checked"'
modèle["checkedOui"] = ""
modèle["enfants"] = ""
modèle["salaire"] = ""
elif état == 300:
# 计算成功 - 显示结果
modèle["success"] = True
modèle["error"] = False
modèle["impôt"] = f"Montant de l'impôt : {résultat['réponse']['impôt']} euros"
modèle["décôte"] = f'Décôte : {résultat["réponse"]["décôte"]} euros'
modèle["réduction"] = f"Réduction : {résultat['réponse']['réduction']} euros"
modèle["surcôte"] = f'Surcôte : {résultat["réponse"]["surcôte"]} euros'
modèle["taux"] = f"Taux d'imposition : {résultat['réponse']['taux'] * 100} %"
# 表单恢复并保留已输入的值
modèle["checkedOui"] = 'checked="checked"' if request.form.get("marié") == "oui" else ""
modèle["checkedNon"] = 'checked="checked"' if request.form.get("marié") == "non" else ""
modèle["enfants"] = request.form.get("enfants")
modèle["salaire"] = request.form.get("salaire")
elif état == 301:
# 发生错误 - 表单已恢复为已输入的值
modèle["checkedOui"] = 'checked="checked"' if request.form.get("marié") == "oui" else ""
modèle["checkedNon"] = 'checked="checked"' if request.form.get("marié") == "non" else ""
modèle["enfants"] = request.form.get("enfants")
modèle["salaire"] = request.form.get("salaire")
# 错误
modèle["success"] = False
modèle["error"] = True
modèle["erreurs"] = ""
for erreur in résultat['réponse']:
modèle['erreurs'] += f"<li>{erreur}</li>"
# 菜单选项
modèle["optionsMenu"] = [
{"text": 'Liste des simulations', "url": '/lister-simulations'},
{"text": 'Fin de session', "url": '/fin-session'}]
# 返回表单
return modèle
注释
- 第 12 行:要显示的视图取决于控制器返回的状态代码;
- 第14-21行:显示一个空表单;
- 第22-35行:税额计算成功的情况。重新显示已输入的数值以及税额;
- 第36-47行:税额计算失败的情况;
- 第49-52行:计算菜单中的两个选项;
32.6.6. 测试 [Postman]
使用请求 [init-session-html-700] 初始化会话 HTML,然后使用请求 [authentifier-utilisateur-200] 进行身份验证。随后我们使用以下请求 [calculer-impot-300]:
服务器的响应如下:


现在尝试以下请求 [calculer-impot-301]:

服务器的响应如下:
现在我们来尝试一个意外情况,即 POST 缺少参数。这种情况在应用程序的正常运行中是不可能发生的。但任何人都可以像我们现在这样“拼凑”出一个 HTTP 请求:


- 在 [6] 中,我们取消了 [marié] 请求中发送的参数;
服务器的响应如下:

- 变为 [3],即服务器的错误信息;
在此应用中,我们有选择权。我们可以为该错误情况分配一个状态码,使其跳转至意外错误页面。在此应用中,我们为每个控制器选择了两个状态码:
- [xx0]:表示成功;
- [xx1]:表示失败;
对于失败情况,我们可以细化状态码以实现更精细的错误管理。例如,我们可以设置:
- [xx1]:用于在引发错误的页面上显示错误;
- [xx2]:用于应用程序正常使用过程中出现的意外错误;
32.7. 模拟列表视图

32.7.1. 视图介绍
显示模拟列表的视图如下:

由代码 [vue-liste-simulations.html] 生成的视图包含三个部分:
- 1:顶部横幅由前文已介绍的片段 [v-bandeau.html] 生成;
- 3:由片段 [v-liste-simulations.html] 生成的模拟表格;
- 2:一个包含两个链接的菜单,由前文已介绍的片段 [v-menu.html] 生成;
模拟视图由以下代码 [vue-liste-simulations.html] 生成:
<!-- 文档 HTML -->
<!doctype html>
<html lang="fr">
<head>
<!-- 必需的元标签 -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- BootstrapCSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css">
<title>Application impôts</title>
</head>
<body>
<div class="container">
<!-- 横幅 -->
{% include "fragments/v-bandeau.html" %}
<!-- 双栏布局 -->
<div class="row">
<!-- 三栏菜单-->
<div class="col-md-3">
{% include "fragments/v-menu.html" %}
</div>
<!-- 9 列模拟列表-->
<div class="col-md-9">
{% include "fragments/v-liste-simulations.html" %}
</div>
</div>
</div>
</body>
</html>
注释
- 第 16 行:包含应用程序标题栏 [1];
- 第21行:插入菜单[2]。该菜单将以三列形式显示在横幅下方;
- 第26行:引入模拟表[3]。该表将以九列形式显示在横幅下方、菜单右侧;
我们已经对该视图中的三个片段中的两个进行了说明:
片段 [v-liste-simulations.html] 如下:
{% if modèle.simulations is undefined or modèle.simulations|length==0 %}
<!-- 蓝色背景上的消息 -->
<div class="alert alert-primary" role="alert">
<h4>Votre liste de simulations est vide</h4>
</div>
{% endif %}
{% if modèle.simulations is defined and modèle.simulations|length!=0 %}
<!-- 蓝色背景上的消息 -->
<div class="alert alert-primary" role="alert">
<h4>Liste de vos simulations</h4>
</div>
<!-- 模拟表格 -->
<table class="table table-sm table-hover table-striped">
<!-- 表格中六个列的标题 -->
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Marié</th>
<th scope="col">Nombre d'enfants</th>
<th scope="col">Salaire annuel</th>
<th scope="col">Montant impôt</th>
<th scope="col">Surcôte</th>
<th scope="col">Décôte</th>
<th scope="col">Réduction</th>
<th scope="col">Taux</th>
<th scope="col"></th>
</tr>
</thead>
<!-- 表格主体(显示的数据) -->
<tbody>
<!-- 通过遍历模拟表来显示每项模拟 -->
{% for simulation in modèle.simulations %}
<!-- 显示包含 6 列的表格中的一行 - <tr> 标签 -->
<!-- 第1列:行标题(模拟编号) - 标签 <th scope='row' -->
<!-- 第2列:参数值 [marié] - 标签 <td> -->
<!-- 第3列:参数值 [enfants] - 标签 <td> -->
<!-- 第 4 列:参数值 [salaire] - 标签 <td> -->
<!-- 第5列:参数值 [impôt](税款) - 标签 <td> -->
<!-- 第6列:参数值 [surcôte] - 标签 <td> -->
<!-- 第 7 列:参数值 [décôte] - 标签 <td> -->
<!-- 第8列:参数值 [réduction] - 标签 <td> -->
<!-- 第 9 列:参数值 [taux](税款) - 标签 <td> -->
<!-- 第10列:模拟删除链接 - 标签 <td> -->
<tr>
<th scope="row">{{simulation.id}}</th>
<td>{{simulation.marié}}</td>
<td>{{simulation.enfants}}</td>
<td>{{simulation.salaire}}</td>
<td>{{simulation.impôt}}</td>
<td>{{simulation.surcôte}}</td>
<td>{{simulation.décôte}}</td>
<td>{{simulation.réduction}}</td>
<td>{{simulation.taux}}</td>
<td><a href="/supprimer-simulation/{{simulation.id}}">Supprimer</a></td>
</tr>
{% endfor %}
</tr>
</tbody>
</table>
{% endif %}
注释
- 一个名为 HTML 的表格使用 <table> 标签创建(第 15 行和第 62 行);
- 表格的列标题位于 <thead> 标签内(表头,第 17、30 行)。<tr> 标签(表行,第 18 和 29 行)界定了一行。第 19-28 行,<th> 标签(表标题)定义了一个列标题。 因此共有十个。[scope="col"] 表示该标题适用于列。[scope="row"] 表示该标题适用于行;
- 第32-61行:<tbody>标签包围了表格显示的数据;
- 第 47-58 行:<tr> 标签用于包裹表格中的一行;
- 第 48 行:<th scope=’row’> 标签定义了该行的表头。浏览器会突出显示该表头;
- 第 49-57 行:每个 <td> 标签(表格数据)定义该行的一列;
- 第34行:模拟列表位于模板[modèle.simulations]中,该模板是一个字典列表;
- 第 57 行:用于删除模拟的链接。URL 模型使用该行中显示的模拟编号;
32.7.2. 视觉测试
我们为视图 [vue-liste-simulations.html] 创建一个测试脚本:
脚本 [test_vue_liste_simulations] 如下:

from flask import Flask, make_response, render_template
# Flask 应用程序
app = Flask(__name__, template_folder="../templates", static_folder="../static")
# 首页 URL
@app.route('/')
def index():
# 将页面数据封装到模板中
modèle = {}
# 将模拟结果转换为页面所需的格式
modèle["simulations"] = [
{
"id": 7,
"marié": "oui",
"enfants": 2,
"salaire": 60000,
"impôt": 448,
"décôte": 100,
"réduction": 20,
"surcôte": 0,
"taux": 0.14
},
{
"id": 19,
"marié": "non",
"enfants": 2,
"salaire": 200000,
"impôt": 25600,
"décôte": 0,
"réduction": 0,
"surcôte": 8400,
"taux": 0.45
}
]
# 菜单
modèle["optionsMenu"] = [
{"text": "Calcul de l'impôt", "url": '/afficher-calcul-impot'},
{"text": 'Fin de session', "url": '/fin-session'}]
# 显示页面
return make_response(render_template("views/vue-liste-simulations.html", modèle=modèle))
# 主菜单
if __name__ == '__main__':
app.config.update(ENV="development", DEBUG=True)
app.run()
注释
- 第12-35行:在模型中添加两个模拟
- 第37-39行:菜单选项表;
通过运行此脚本显示该视图。结果如下:

对该视图进行调整,直到视觉效果令人满意。随后即可将其集成到正在开发的Web应用程序中。
32.7.3. 视图模型的计算
确定视图的视觉效果后,即可在实际环境中计算视图模型。回顾一下引导至该视图的状态代码。这些代码位于配置文件中:

{
# 模拟列表视图
"états": [
# /列出-模拟
500,
# /删除-模拟
600
],
"view_name": "views/vue-liste-simulations.html",
"model_for_view": ModelForListeSimulationsView()
}
因此,正是状态代码 [500, 600] 触发了模拟视图的显示。要了解这些代码的含义,可以参考在应用程序 jSON 上执行的测试 [Postman]:
- [lister-simulations-500]:500是[lister-simulations]操作成功后的状态码:此时将显示用户已执行的模拟列表;
- [supprimer-simulation-600]:600是[supprimer-simulation]操作成功后的状态码。此时将显示删除操作后生成的新的模拟列表;
既然我们已经知道在何时需要显示模拟列表,就可以在类 [ModelForListeSimulationsView] 中计算其模型:
from flask import Request
from werkzeug.local import LocalProxy
from InterfaceModelForView import InterfaceModelForView
class ModelForListeSimulationsView(InterfaceModelForView):
def get_model_for_view(self, request: Request, session: LocalProxy, config: dict, résultat: dict) -> dict:
# 将页面数据封装到模板中
modèle = {}
# 在执行该操作的控制器响应中找到模拟
# 以字典数组的形式呈现TaxPayer
modèle["simulations"] = résultat["réponse"]
# 菜单
modèle["optionsMenu"] = [
{"text": "Calcul de l'impôt", "url": '/afficher-calcul-impot'},
{"text": 'Fin de session', "url": '/fin-session'}]
# 生成模板
return modèle
注释
- 第 13 行:待显示的模拟数据位于 [résultat["réponse"]] 中;
- 第15-17行:要显示的菜单选项;
32.7.4. 测试 [Postman]
正在
- 初始化一个会话 HTML;
- 进行身份验证;
- 进行三次税款计算;
测试 [lister-simulations-500] 使我们获得状态码 500。这对应于查看模拟结果的请求:

服务器的响应如下:

测试 [supprimer-simulation-600] 返回状态码 600。此处我们将删除第 2 号模拟。
返回的结果是已减少一个模拟项的模拟列表:


32.8. 意外错误视图
此处所指的“意外错误”,是指在正常使用Web应用程序时本不应发生的错误。例如,在未经过身份验证的情况下请求计算税款。没有任何机制能阻止用户直接在浏览器中输入URL [/calcul-impot]。 此外,正如我们所见,用户还可以通过不发送预期的参数,对 URL [/calcul-impot] 进行 POST 操作。我们已经看到,我们的 Web 应用程序能够正确响应此请求。 我们将那些在 HTML 应用程序运行过程中不应出现的错误称为“意外错误”。如果发生此类错误,很可能是有人试图“入侵”该应用程序。出于教学目的,我们决定针对此类情况显示一个错误视图。 实际上,我们可以重新显示发送给客户端的上一页。只需将最后发送的响应 HTML 保存在会话中即可。一旦发生意外错误,我们就返回该响应。这样,由于显示的页面没有变化,用户会觉得服务器对错误没有响应。
32.8.1. 视图介绍

显示意外错误的视图如下:

由代码 [vue-erreurs.html] 生成的视图包含三个部分:
- 1:顶部横幅由前文已介绍的片段 [v-bandeau.html] 生成;
- 2:意外错误;
- 3:一个包含三个链接的菜单,由前文已介绍的片段 [v-menu.html] 生成;
意外错误的显示由以下脚本 [vue-erreurs.html] 生成:
<!-- 文档 HTML -->
<!doctype html>
<html lang="fr">
<head>
<!-- 必需的元标签 -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- BootstrapCSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css">
<title>Application impôts</title>
</head>
<body>
<div class="container">
<!-- 12 列横幅 -->
{% include "fragments/v-bandeau.html" %}
<!-- 两栏布局 -->
<div class="row">
<!-- 3 列菜单-->
<div class="col-md-3">
{% include "fragments/v-menu.html" %}
</div>
<!-- 9列错误列表 -->
<div class="col-md-9">
<div class="alert alert-danger" role="alert">
Les erreurs inattendues suivantes se sont produites :
<ul>{{modèle.erreurs|safe}}</ul>
</div>
</div>
</div>
</div>
</body>
</html>
注释
- 第 16 行:包含应用程序 [1] 的横幅;
- 第 21 行:包含菜单 [3]。该菜单将以三列形式显示在横幅下方;
- 第24-29行:以九列形式显示错误区域;
- 第25行:该区域将显示在粉色背景的Bootstrap框架内;
- 第26行:一段介绍性文字;
- 第27行:<ul>标签包裹了一个项目符号列表。该列表由模板[modèle.erreurs]提供;
我们已对该视图的两个片段进行了说明:
32.8.2. 视觉测试
我们为视图 [vue-erreurs.html] 创建了一个测试脚本:

from flask import Flask, render_template, make_response
# Flask 应用程序
app = Flask(__name__, template_folder="../templates", static_folder="../static")
# 首页 URL
@app.route('/')
def index():
# 将页面数据封装到模板中
modèle = {}
# 构建错误列表 HTML
content = ""
for erreur in ["erreur1", "erreur2"]:
content += f"<li>{erreur}</li>"
modèle["erreurs"] = content
# 菜单选项
modèle["optionsMenu"] = [
{"text": "Calcul de l'impôt", "url": '/calculer-impot'},
{"text": 'Liste des simulations', "url": '/lister-simulations'},
{"text": 'Fin de session', "url": '/fin-session'}]
# 显示页面
return make_response(render_template("views/vue-erreurs.html", modèle=modèle))
# 主页
if __name__ == '__main__':
app.config.update(ENV="development", DEBUG=True)
app.run()
注释
- 第11-15行:构建错误列表HTML;
- 第17-20行:菜单选项表;
运行该脚本。结果如下:
对该视图进行调整,直到视觉效果令我们满意为止。随后,我们可以将其集成到正在开发的 Web 应用程序中。

32.8.3. 视图模型的计算

确定视图的外观后,即可在实际环境中计算视图模型。回顾一下通往该视图的状态代码。这些代码位于配置文件中:
# 视图 HTML 及其模板取决于控制器返回的状态
"views": [
{
# 身份验证视图
"états": [
# /init-session 成功
700,
# /结束会话
400,
# /用户认证失败
201
],
"view_name": "views/vue-authentification.html",
"model_for_view": ModelForAuthentificationView()
},
{
# 税款计算视图
"états": [
# /用户认证成功
200,
# /计算-税款 成功
300,
# /计算-税款 失败
301,
# /显示-税款计算
800
],
"view_name": "views/vue-calcul-impot.html",
"model_for_view": ModelForCalculImpotView()
},
{
# 模拟列表视图
"états": [
# /列出模拟
500,
# /删除-模拟
600
],
"view_name": "views/vue-liste-simulations.html",
"model_for_view": ModelForListeSimulationsView()
}
],
# 意外错误视图
"view-erreurs": {
"view_name": "views/vue-erreurs.html",
"model_for_view": ModelForErreursView()
},
第3-41行中那些不指向视图HTML的状态代码,会触发意外错误视图的显示。
视图 [vue-erreurs.html] 的模型计算由以下类 [ModelForErreursView] 完成:
from flask import Request
from werkzeug.local import LocalProxy
from InterfaceModelForView import InterfaceModelForView
class ModelForErreursView(InterfaceModelForView):
def get_model_for_view(self, request: Request, session: LocalProxy, config: dict, résultat: dict) -> dict:
# 模型
modèle = {}
# 错误
modèle["erreurs"] = ""
for erreur in résultat['réponse']:
modèle['erreurs'] += f"<li>{erreur}</li>"
# 菜单
modèle["optionsMenu"] = [
{"text": "Calcul de l'impôt", "url": '/afficher-calcul-impot'},
{"text": 'Liste des simulations', "url": '/lister-simulations'},
{"text": 'Fin de session', "url": '/fin-session'}]
# 生成模型
return modèle
注释
- 第 11-14 行:计算视图 [vue-erreurs.html] 所使用的模板 [modèle.erreurs];
- 第 16-197 行:计算由片段 [v-menu.html] 使用的模板 [modèle.optionsMenu];
32.8.4. [Postman] 测试
执行:
- 执行操作 [/init-session/html];
- 随后执行操作 [/init-session/x];
此时响应 HTML 如下:

32.9. 应用程序菜单操作的实现
接下来我们将讨论菜单操作的实现。回顾一下我们之前遇到的链接含义
视图 | 链接 | 目标 | 作用 |
计算税款 | [Liste des simulations] | [/lister-simulations] | 请求模拟列表 |
[Fin de session] | |||
模拟列表 | [Calcul de l’impôt] | [/afficher-calcul-impot] | 显示税款计算视图 |
[Fin de session] | |||
意外错误 | [Calcul de l’impôt] | [/afficher-calcul-impot] | 显示税额计算视图 |
[Liste des simulations] | |||
[Fin de session] |
需要注意的是,点击链接会触发 GET 跳转至链接目标。[/lister-simulations, /fin-session] 操作已通过 GET 操作实现,因此我们可以将其设置为链接目标。 当操作通过 POST 实现时,除非结合 JavaScript 使用,否则无法再使用链接。
32.9.1. 操作 [/afficher-calcul-impot]
从上述操作来看,[/afficher-calcul-impot] 操作似乎尚未实现。 这是一项在两个视图之间进行导航的操作:jSON或XML服务器没有理由实现它,因为它们不具备视图的概念。是HTML服务器引入了这一概念。
因此,我们需要实现 [/afficher-calcul-impot] 操作。这将使我们能够回顾在服务器内部实现操作的流程。
首先,我们需要添加一个新的子控制器。我们将它命名为 [AfficherCalculImpotController]:

该控制器需添加到配置文件 [config] 中:
# 控制器
from AfficherCalculImpotController import AfficherCalculImpotController
from AuthentifierUtilisateurController import AuthentifierUtilisateurController
from CalculerImpotController import CalculerImpotController
from CalculerImpotsController import CalculerImpotsController
from FinSessionController import FinSessionController
from GetAdminDataController import GetAdminDataController
…
# 允许的操作及其控制器
"controllers": {
# 计算会话的初始化
"init-session": InitSessionController(),
# 用户身份验证
"authentifier-utilisateur": AuthentifierUtilisateurController(),
# 个人模式下的税款计算
"calculer-impot": CalculerImpotController(),
# 批量模式下的税款计算
"calculer-impots": CalculerImpotsController(),
# 模拟列表
"lister-simulations": ListerSimulationsController(),
# 删除模拟
"supprimer-simulation": SupprimerSimulationController(),
# 结束计算会话
"fin-session": FinSessionController(),
# 显示税款计算视图
"afficher-calcul-impot": AfficherCalculImpotController(),
# 获取税务部门数据
"get-admindata": GetAdminDataController(),
# 主控制器
"main-controller": MainController()
},
…
# 视图 HTML 及其模板取决于控制器返回的状态
"views": [
{
# 身份验证视图
…
},
{
# 税款计算视图
"états": [
# /用户认证成功
200,
# /计算-税款 成功
300,
# /计算-税款 失败
301,
# /显示-税款-计算
800
],
"view_name": "views/vue-calcul-impot.html",
"model_for_view": ModelForCalculImpotView()
},
{…
}
],
- 第 2 行:新控制器;
- 第28行:新操作及其控制器;
- 第51行:新控制器将返回状态码800。在视图切换时,不应出现错误。当前显示的视图是[vue-calcul-impot.html],我们已对其进行了研究、说明和测试;
[AfficherCalculImpotController] 控制器将如下所示:
from flask_api import status
from werkzeug.local import LocalProxy
from InterfaceController import InterfaceController
class AfficherCalculImpotController(InterfaceController):
def execute(self, request: LocalProxy, session: LocalProxy, config: dict) -> (dict, int):
# 获取路径中的元素
dummy, action = request.path.split('/')
# 视图切换——只需设置状态码
return {"action": action, "état": 800, "réponse": ""}, status.HTTP_200_OK
注释
- 第6行:与其他辅助控制器一样,新控制器实现了[InterfaceController]接口;
- 第13行:视图切换的实现很简单:只需返回与目标视图关联的状态码,此处如前所述为代码800;
32.9.2. 操作 [/fin-session]
操作 [/fin-session] 较为特殊。它不会直接跳转到视图,而是进行重定向。需要提醒的是,重定向已在配置 [config] 中按以下方式配置:
# 重定向
"redirections": [
{
"états": [
400, # /注销成功
],
# 重定向至
"to": "/init-session/html",
}
],
该应用程序中仅存在一次重定向:
- 当控制器返回状态码 [400](第 5 行)时,需将客户端重定向至 URL [http://machine:port/chemin/init-session/html](第 8 行);
状态码 [400] 是 [/fin-session] 操作成功后返回的代码。那么,为什么还要将客户端重定向到 URL 和 [/init-session/html] 呢? 因为操作代码 [/fin-session] 会清除 Web 会话中的会话类型。此时系统将无法识别当前处于 HTML 会话中,因此必须进行重定向。我们通过操作代码 [/init-session/html] 来实现这一重定向。
HTML的重定向由类[HtmlResponse]管理:
def build_http_response(self, request: LocalProxy, session: LocalProxy, config: dict, status_code: int,
résultat: dict) -> (Response, int):
# 响应HTML取决于控制器返回的状态码
état = résultat["état"]
# 是否需要进行重定向?
for redirection in config["redirections"]:
# 需要重定向的状态
états = redirection["états"]
if état in états:
# 需要进行重定向
return redirect(f"{redirection['to']}"), status.HTTP_302_FOUND
# 每个状态对应一个视图
# 在视图列表中查找该视图
..
- 第 6-12 行处理重定向;
- 第 7 行:config[‘redirections’] 是一个重定向列表。每个重定向都是一个包含以下键的字典:
- [états]:导致重定向的控制器返回状态;
- [to]:重定向地址;
- 第 7-12 行:遍历重定向列表;
- 第 9 行:对于每个重定向,获取导致该重定向的状态;
- 第 10 行:如果被测试的状态在此列表中,则执行重定向,见第 12 行;
- 第12行:需注意方法[build_http_response]必须返回一个包含两个元素的元组:
- [response]:待生成的响应 HTTP。该响应由函数 [redirect] 构建,其参数为重定向地址;
- [status_code]:响应 HTTP 的状态码,此处为 [status.HTTP_302_FOUND],该代码指示客户端进行重定向;
让我们进行一次测试 [Postman]。我们:
- 初始化一个会话 HTML [init-session/html];
- 进行身份验证 [/authentifier-utilisateur];
- 结束会话 [/fin-session];

服务器的响应如下:

我们已获取到身份验证视图。这正是我们所期待的。现在,让我们看看它是如何获取的。切换到控制台 [Postman] (Ctrl-Alt-C):

- 在 [1] 中,操作 [/fin-session];
- 变为 [2-3],服务器返回的 HTTP 状态码 302 告知客户端将进行重定向;
- 在 [4] 中,客户端 [Postman] 跟随重定向;
32.10. HTML 应用程序在实际环境中的测试
代码已编写完毕,并使用[Postman]对每个操作进行了测试。接下来我们需要在实际环境中测试视图的流转。我们需要一种方法来初始化HTML会话。 我们知道需要向服务器发送请求 [/init-session/html]。但这并不是一个非常实用的 URL。我们更希望从 URL [/] 开始。
我们在主脚本 [main] 中编写了以下路径:
from flask import request, Flask, session, url_for, redirect
…
…
@app.route('/', methods=['GET'])
def index() -> tuple:
# 重定向至 /init-session/html
return redirect(url_for("init_session", type_response="html"), status.HTTP_302_FOUND)
…
# init-session
@app.route('/init-session/<string:type_response>', methods=['GET'])
def init_session(type_response: str) -> tuple:
# 执行与该操作关联的控制器
return front_controller()
- 第4-7行:路由管理 [/]。 Web 应用程序的入口点将是 URL[/init-session/html](第 10 行)。此外,在第 7 行,我们将客户端重定向到 URL:
- 函数 [url_for] 在第 1 行被导入。该函数在此处有两个参数(第 7 行):
- 第一个参数是路由函数的名称,此处即第11行的函数。可见该函数期待一个名为[type_response]的参数,该参数表示客户端期望的响应类型(json、xml、html);
- 第二个参数采用第11行参数的名称 [type_response],并为其赋值。如果还有其他参数,则需对每个参数重复此操作;
- 该操作将URL与由给定两个参数指定的函数关联起来。在此处,这将生成第10行中的URL,其中参数被其值[/init-session/html]替换;
- 函数 [redirect] 已在第 1 行被导入。其作用是向客户端发送一个 HTTP 重定向头部:
- 第一个参数是客户端应被重定向到的 URL;
- 第二个参数是发给客户端的响应状态码。状态码 [status.HTTP_302_FOUND] 表示重定向;
准备就绪。现在我们展示一些视图流。
在浏览器中,我们启用请求跟踪(Chrome、Firefox、Edge中的F12),并请求启动[http://localhost:5000/]的URL。服务器的响应如下:

若观察客户端与服务器之间的网络通信:

- 可以看到,在 [4, 5] 时,浏览器收到了一条重定向请求,指向 URL [/init-session/html];
填写我们收到的表单;

然后进行一些模拟操作:


请求模拟列表:

删除第一个模拟:

结束本次会话:

欢迎读者进行其他测试。