精通-Ext-js-全-

精通 Ext.js(全)

原文:zh.annas-archive.org/md5/1e09dda16cba7985dea820f54fa1924f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

如果你是一名 Ext JS 开发者,你可能花费了不少时间来学习这个框架。我们知道 Ext JS 的学习曲线并不短。在我们掌握了基础知识之后,当我们需要在日常工作中使用 Ext JS 时,会涌现出很多问题:一个组件如何与另一个组件通信?最佳实践是什么?使用这种方法而不是另一种方法是否真的值得?还有没有其他方式可以实现相同的功能?这是正常的。

本书是考虑到这些开发者而编写的。

因此,这就是本书的主题:我们如何将所有内容整合在一起,并使用 Ext JS 创建真正出色的应用程序?我们将创建一个完整的应用程序,从屏幕草图到将其投入生产。我们将创建应用程序结构、启动画面、登录页面、多语言功能、活动监视器、依赖于用户权限的动态菜单以及用于管理数据库信息(简单和复杂信息)的模块。然后,我们将学习如何为生产构建应用程序、如何自定义主题以及如何调试它。

我们将使用现实世界的示例,看看我们如何使用 Ext JS 组件来实现它们。在整个书中,我们还包含了很多技巧和最佳实践,以帮助您提升 Ext JS 知识并达到新的水平。

本书涵盖的内容

第一章, Sencha Ext JS 概述,介绍了 Sencha Ext JS 及其功能。本章提供了在深入阅读本书其他章节之前可以阅读的参考资料。这是考虑到这可能是你第一次接触这个框架而进行的。

第二章, 入门,介绍了本书中实现的应用程序,其功能和每个屏幕及模块的草图(每个章节涵盖不同的模块),并展示了如何使用 Sencha Cmd 创建应用程序的结构以及如何创建启动画面。

第三章, 登录页面,解释了如何使用 Ext JS 创建登录页面以及如何在服务器端处理它,并展示了额外的功能,例如添加大写锁定警告消息以及在按下 Enter 键时提交登录页面。

第四章, 退出和多语言功能,涵盖了如何创建退出功能以及客户端活动监视器超时,这意味着如果用户没有使用鼠标或按下键盘上的任何键,系统将自动结束会话并退出。本章还提供了一个多语言功能的示例,并展示了如何创建一个组件,用户可以使用它来更改系统的语言和区域设置。

第五章,高级动态菜单,是关于如何创建一个依赖于用户权限的动态菜单。菜单选项的渲染取决于用户是否有权限;如果没有,则选项将不会显示。

第六章,用户管理,解释了如何创建一个屏幕来列出已经访问系统的所有用户。

第七章,静态数据管理,涵盖了如何实现一个模块,用户能够像直接从 MySQL 表编辑信息一样编辑信息。本章还探讨了诸如实时搜索、筛选和内联编辑(使用单元格编辑和行编辑插件)等功能。此外,我们开始探讨使用 Ext JS 开发大型应用程序时遇到的真实世界问题,例如在整个应用程序中组件的重用。

第八章,内容管理,进一步探讨了从数据库表及其与其他表的所有关系来管理信息复杂性。因此,我们介绍了如何管理复杂信息以及如何在数据网格和表单面板中处理关联。

第九章,添加额外功能,涵盖了如何添加诸如打印以及将内容导出为 PDF 和 Excel 等功能,这些功能不是 Ext JS 本地支持的。本章还涵盖了图表以及如何将它们导出为图像和 PDF,以及如何使用第三方插件。

第十章,路由、触摸支持和调试,演示了如何在项目中启用路由;它还涉及调试 Ext JS 应用程序,包括我们需要注意的事项以及为什么了解如何调试非常重要。我们还简要讨论了将 Ext JS 项目转换为移动应用(响应式设计和触摸支持),一些有助于您作为开发者日常工作的有用工具,以及一些关于在哪里找到额外和开源插件以用于 Ext JS 项目的推荐。

第十一章,准备生产和主题,涵盖了如何自定义主题和创建自定义用户界面。它还探讨了将应用程序打包到生产所需的步骤以及这样做的好处。

您需要为此书准备的内容

以下是在执行本书示例之前需要安装的软件列表。以下列表涵盖了实现和执行本书示例所使用的确切软件,但您可以使用任何具有相同功能的已安装类似软件。

对于带有调试工具的浏览器,请使用以下方法:

对于支持 PHP 的 Web 服务器,请使用以下:

对于数据库,请使用以下:

对于 Sencha Cmd 和所需的工具,请使用以下:

本书将使用 Ext JS 5.0.1。

本书面向对象

如果您是一位熟悉 Ext JS 的开发者,并希望提升您的技能以创建更好的 Web 应用程序,这本书就是为您准备的。需要具备 JavaScript/HTML/CSS 的基本知识以及任何服务器端语言(PHP、Java、C#、Ruby 或 Python)的基础知识。

习惯用法

在本书中,您将找到许多用于区分不同类型信息的文本样式。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称应如下所示:“如果我们想创建一个表示客户详情的类,我们可以将其命名为 ClientDetails。”

代码块应如下设置:

Ext.define('Packt.model.film.Film', {
    extend: 'Packt.model.staticData.Base', //#1

当我们希望引起您对代码块中特定部分的注意时,相关的行或项目将以粗体显示:

Ext.application({
    name: 'Packt',

    extend: 'Packt.Application',

 autoCreateViewport: 'Packt.view.main.Main'
});

任何命令行输入或输出都应如下所示:

sencha generate app Packt ../masteringextjs

新术语重要词汇将以粗体显示。屏幕上显示的词汇,例如在菜单或对话框中,将以如下方式显示:“滚动到页面底部并选择开源 GPL 许可。”

注意

警告或重要提示将以如下框显示。

小贴士

小贴士和技巧将以如下方式显示。

读者反馈

我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要发送一般反馈,请简单地将电子邮件发送到 <feedback@packtpub.com>,并在邮件主题中提及书籍的标题。

如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从www.packtpub.com下载示例代码文件,这些文件适用于您购买的所有 Packt 出版社书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

勘误

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过以下链接联系我们 <copyright@packtpub.com>,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和为我们提供有价值内容的能力方面所提供的帮助。

问题和答案

如果您对本书的任何方面有问题,您可以联系我们的 <questions@packtpub.com>,我们将尽力解决问题。

第一章. Sencha Ext JS 概述

现在,市场上有很多前端框架和库的版本。如果你只想操作 文档对象模型 (DOM),有你可以使用的框架;如果你只想用于样式,有你可以使用的框架;有用于用户友好组件的框架;有用于设计项目的框架,等等。还有 Ext JS,这是一个用于创建 富互联网应用 (RIA) 的框架,但它不仅仅有漂亮的组件,还有许多其他功能。

在这本书中,我们将学习如何使用 Ext JS 5 从头到尾开发一个应用程序,同时涵盖使我们的应用程序工作所需的后端的一些部分。我们将通过一些实际示例学习如何使用 Ext JS,包括一些组件、它们的工作原理以及如何在每一章中使用它们。

但首先,如果你是第一次接触这个框架,你将学习 Ext JS 能够做什么。

理解 Sencha Ext JS

我们可以使用 Ext JS 来操作 DOM 吗?如果我们想要漂亮且用户友好的组件(表单、网格、树等),我们可以使用它吗?如果我们需要一些漂亮的图表,我们可以使用它吗?我们可以使用 Ext JS 的 模型-视图-控制器 (MVC) 架构来组织应用程序吗?如果我们想在模型和视图之间使用双向数据绑定,我们可以使用 Ext JS 做到这一点吗?如果我们不喜欢 Ext JS 组件的外观和感觉的颜色,我们可以轻松地更改它吗?现在是一个难题;我们可以使用 Ext JS 对我们的应用程序的 CSS 和 JavaScript 文件进行构建以混淆和优化吗?Ext JS 是否响应式?我们可以在移动设备上使用它吗?

令人惊讶的是,所有前面的问题的答案都是肯定的!正如我们所见,Ext JS 是一个完整的前端框架。Ext JS 背后的智囊公司是 Sencha Inc. (sencha.com)。

Sencha Ext JS 还有一个叫做 Sencha Touch 的表亲。它也有我们刚才提到的惊人特性,但专注于移动跨平台世界。我们将在本书的后续章节中非常简要地讨论 Ext JS 和 Sencha Touch。

Ext JS 应用程序的架构

在我们开始之前,让我们确保我们理解了一些核心概念。Ext JS 是一个基于 JavaScript 和 HTML5 的前端框架。这意味着 Ext JS 并不直接连接到数据库。对于存储,我们可以使用 HTML5 存储的一种类型,例如 Web SQL 或本地存储,但这些存储类型只能让我们存储 5 MB 的数据,这对于一个普通应用来说非常少。

通常,我们希望使用 MySQL、Oracle、MS Server 或任何其他数据库。为了能够在数据库中存储信息,我们需要使用一种服务器端语言,例如 PHP、Java、C#、Ruby、Python、Node.js 等。Ext JS 将与服务器端语言(或 Web 服务)进行通信,服务器将连接到数据库或其他存储(例如文档库)。

以下图表展示了使用 Ext JS 开发的应用程序架构:

Ext JS 应用程序架构

Ext JS 概述

我们已经提到了一些 Ext JS 的功能。让我们简要地看一下每一个。但首先,如果你想查看官方 Sencha Ext JS 网页,请访问 www.sencha.com/products/extjs/

基础教程

在深入本书之前,建议您阅读以下链接的内容。它们包含任何开发者开始使用 Ext JS 之前需要了解的基本信息:

类系统

Ext JS 使用面向对象(OO)的方法。我们使用在 Ext JS 中称为配置和方法的属性声明类(JavaScript 中的函数)。

Ext JS 也遵循一种命名约定。如果你熟悉面向对象编程,你可能也熟悉 Ext JS 的命名约定。例如,类名是字母数字的,以大写字母开头,其余字母采用驼峰式。例如,如果我们想创建一个表示客户端详情的类,我们可以将其命名为 ClientDetails。方法和属性名以小写字母开头,其余字母采用驼峰式。例如,retrieveClientDetails() 是一个良好的方法名,而 clientName 是一个良好的属性名。

Ext JS 也按照包进行组织。包是一种组织具有相同目的代码的方式。例如,在 Ext JS 中,有一个名为 data 的包,用于处理框架中与数据相关的一切。还有一个名为 grid 的包,包含所有与 GridPanels 相关的代码。

注意

关于类系统的更多信息,请阅读 docs.sencha.com/extjs/5.0/core_concepts/classes.html

组件

一些人们考虑使用 Ext JS 的主要原因可能是因为其丰富且用户友好的组件。Ext JS 包含了网络应用程序中最常用的组件,如表单、网格和树。我们还可以使用触摸友好的图表(意味着它们在触摸屏上也能工作)以及使用所有 可缩放矢量图形 (SVG) 和 HTML5 优势的绘图包。

您可以查看官方 Sencha Ext JS 示例页面dev.sencha.com/extjs/5.0.0/examples/index.html,以了解我们可以用示例做什么。

组件层次结构

您会注意到,在这本书的整个过程中,我们将提到诸如组件、容器和小部件之类的术语。以下图表展示了 Ext JS 中的组件层次结构:

组件层次结构

组件类是所有 Ext JS 小部件的父类。在组件类下面,我们有容器类。容器类可能包含其他组件。例如,让我们看看以下 GridPanel:

组件层次结构

Grid Panel类扩展自Panel类,这是 Ext JS 中一个非常流行的组件。Panel类支持标题、停靠项(工具栏),并且包含一个主体空间。Panel类的子类,如DataViewTreeGridForm,是面板,但它们没有主体,而是有一个专门的View类,负责渲染特定信息。例如,Grid面板的View类专门用于渲染网格列;Tree面板的View类专门用于渲染层次信息,而Form面板(称为 BasicForm)的View类专门用于渲染表单字段。

GridPanel

网格组件是网络应用中最常用的组件之一。它用于显示表格数据。

要创建网格,开发者需要声明至少两个配置:columnsStoreStore类在 Ext JS 中组织数据集合,并负责向网格提供要显示的信息。我们将在讨论数据包时探讨它。

网格组件可以用作简单的数据网格,只显示信息记录。或者,如果我们有大量数据,我们可以使用其分页功能,或者如果我们真的有大量数据,我们也可以使用大数据网格。还有其他功能,例如分组表头网格(也称为 Pivot Grid);我们还可以拥有带有锁定列甚至带有小部件的网格,例如聊天,如前一个截图所示。在其他功能中,我们还可以对网格内的信息进行排序和筛选,并使用一些插件来完成诸如展开行以显示更多信息而不弹出、使用复选框选择行以及自动编号行等任务。还有更多:网格组件还支持通过打开一个小弹出行来编辑信息,以便您可以直接在网格中编辑信息。网格还支持单元格编辑,这与我们在 MS Excel 中可以做的类似——通过双击单元格来编辑信息。

注意

如需更多信息,请参阅docs.sencha.com/extjs/5.0/apidocs/#!/api/Ext.grid.Paneldocs.sencha.com/extjs/5.0/components/grids/grids.html

TreePanel

树显示层次数据,例如文件目录。树的数据来自 TreeStore 或预定义在root配置中。树还支持排序和过滤,可以使用复选框选择行,我们还可以将树与网格混合,并使用 TreeGrid 组件。

它还支持插件,如树之间的拖放。

注意

如需更多信息,请参阅docs.sencha.com/extjs/5.0/apidocs/#!/api/Ext.tree.Paneldocs.sencha.com/extjs/5.0/components/trees.html

表单

接下来,我们有表单组件。我们可以使用文本、区域和数字字段实现强大的表单。我们还可以使用日期/月份选择器、复选框、单选按钮、组合框,甚至文件上传。所有字段都有基本的原生验证支持(带有错误消息给用户),例如必填字段和最小值或最大值或长度,但我们可以轻松自定义并创建自定义验证(例如 IP 地址)。

注意

如需更多信息,请参阅docs.sencha.com/extjs/5.0/apidocs/#!/api/Ext.form.Paneldocs.sencha.com/extjs/5.0/components/forms.html

其他组件

我们还有图表。我们可以构建柱状图、条形图、折线图、面积图、散点图、饼图、径向图、仪表图,甚至金融图表。我们还可以有基本图、堆叠图、多轴图和 3D 图表。图表也由 Store 提供数据。

当然,还有一些基本组件可以帮助我们的应用程序看起来更好,例如菜单、标签页、面板、窗口、警报、工具栏等等。这些组件具有Web Accessibility Initiative – Accessible Rich Internet Applications (WAI-ARIA)支持,并且也支持从右到左的语言。

看起来不错,对吧?我们将通过本书中的示例来介绍大部分组件及其功能。

布局

Ext JS 支持不同的可能性。它还有一个出色的布局管理器(只有当我们使用其基础组件(Viewport)创建 Ext JS 应用程序时,布局管理器才可用。对于在独立表单(在<div>标签中渲染)中使用的组件,当减小浏览器窗口大小时,布局管理器不起作用)。

支持的布局中包括绝对布局(在这种情况下,我们需要使用组件在屏幕或组件内的绝对xy位置);手风琴布局、边界布局、卡片布局、中心布局、列布局、适应布局、Hbox 和 Vbox 布局,以及表格布局。

在应用程序中最常用的布局是边框、卡片、适应和 HBox 以及 VBox。我们将在本书的示例中介绍不同的布局。

注意

如需更多信息,请查看dev.sencha.com/ext/5.0.1/examples/kitchensink/#layouts以及docs.sencha.com/extjs/5.0/apidocs/#!/api/Ext.layout.container.Absolute中的layout.container包。

数据包

data包是 Ext JS SDK 中最重要的包之一。Ext JS 组件如网格、树形图,甚至表单都是数据驱动的。

服务器端语言通常支持数据较好。在 Java、PHP、C#和其他语言中,我们可以创建称为纯 Java 对象POJOs)、持久化域对象PDOs)和值对象VOs)的实体,以及其他我们通常给这些实体取的名字。Ext JS 支持数据,因此我们也在前端表示实体。

基本上有三个主要部分:

  • 模型: 这代表实体。它可以代表服务器端的一个类或数据库中的一个表。模型支持字段、验证、关联(一对一一对多多对多)。

  • 存储: 这代表模型集合。它还支持分组、过滤和排序。

  • 代理: 这代表我们将如何连接到服务器(或本地存储)。它可以是 Ajax、REST、JSONP、内存或 HTML5 LocalStorage。在代理内部,我们可以定义ReaderWriterReader属性负责解码我们从服务器接收到的数据(如果它是 JSON 或 XML,我们也可以定义其格式)。Writer属性负责将数据编码为要发送到服务器的数据;它可以是 JSON 或 XML,我们也可以定义其格式。代理可以放在模型或存储内部。

注意

如需更多信息,请阅读docs.sencha.com/extjs/5.0/core_concepts/data_package.html

MVC 和 MVVM 架构

在使用 Ext JS 时,我们可以为前端代码选择两种架构:模型-视图-控制器MVC)和模型-视图-视图模型MVVM)。还有一个第三种选择,即MVCMVVM之间的混合架构。

在本书中,我们将学习更多关于 MVC、MVVM 以及混合方法的内容。

注意

如需更多信息,请阅读docs.sencha.com/extjs/5.0/application_architecture/application_architecture.html

Ext JS 应用程序的外观和感觉

我们还可以自定义 Ext JS 应用程序的主题。主题基于 Sass 和 Compass。我们将在本书的最后一章深入探讨主题。

注意

更多信息请参阅 docs.sencha.com/extjs/5.0/core_concepts/theming.html

安装 Ext JS

让我们看看如何在本地安装 Ext JS。这一步是必需的,因为我们将在使用 Sencha Cmd 创建应用程序之前需要在我们的计算机上拥有 Ext JS SDK。

Ext JS 和 Sencha Cmd 的先决条件

在下载 Ext JS 和 Sencha Cmd 之前,我们需要设置我们的计算机以准备就绪。以下是我们创建 Ext JS 应用程序所需的软件列表:

  1. Ruby 1.8 或 1.9:撰写本文时,Ruby 的当前版本是 2.x。为了能够创建 Ext JS 应用程序,我们需要安装 Ruby 1.8 或 1.9。Ruby 是必需的,因为 Ext JS 所使用的主题引擎基于 Sass 和 Compass,它们是 Ruby gems。要下载和安装 Ruby,请遵循 www.ruby-lang.org/en/installation/ 中的说明。

  2. Sass 和 Compass:这些不是 CSS 框架。Sass 是一种新的编写 CSS 的方法。可以使用变量并定义函数和混入。它是 Less 的替代品(也许你使用过 Less 或者听说过它——Sass 非常相似)。在下载并安装 Ruby 之后,请安装 Sass。安装说明可以在 sass-lang.com/install 找到(遵循命令行说明)。Compass 是一个 Sass 框架,也是必需的。请从 compass-style.org/install/ 安装它。Sass 和 Compass 是 Ext JS 主题引擎的核心。我们将为我们的应用程序创建的所有自定义 CSS 都将由 Sass/Compass 编译。

  3. Java JDK:如果你是 Java 开发者,你可能已经安装了 Java JDK。如果没有,请下载并执行 www.oracle.com/technetwork/articles/javase/index-jsp-138363.html 中的安装程序。安装 Java JDK 后,我们还需要配置 JAVA_HOME 环境变量。说明可以在 goo.gl/JFtKHF 找到。Java JDK 是必需的,因为我们的下一步是 ANT。

  4. Apache ANT:创建应用程序和构建应用程序的 Sencha Cmd 引擎基于 ANT,一个 Java 库。我们需要从 ant.apache.org/bindownload.cgi 下载 ANT,将其解压到我们选择的目录中,并设置 ANT_HOME 环境变量 (ant.apache.org/manual/install.html)。

我们可以通过在终端应用程序中执行以下命令来检查我们是否拥有正确的环境:

Ext JS 和 Sencha Cmd 的先决条件

注意,安装的 Ruby 版本是 2.x,但只要您的 classpath 中有 1.8 或 1.9 兼容版本,您应该没问题。

最后一步是设置一个 Web 服务器。我们可以使用的最简单的服务器是 Apache Xampp。请下载并按照www.apachefriends.org上的安装说明进行操作。

注意

本书提到的所有软件环境均适用于 Linux、Windows 和 Mac OS。

下载 Ext JS 和 Sencha Cmd

现在我们已经配置好了环境,我们可以下载 Ext JS。Ext JS 有一些不同的许可版本:商业版和开源版。对于本书,我们将使用开源版。您可以在www.sencha.com/products/extjs/details下载开源版本。滚动到页面底部并选择OPEN SOURCE GPL LICENSING,如下截图所示:

下载 Ext JS 和 Sencha Cmd

注意

本书撰写时,Ext JS 的最新版本是 5.1。

我们还需要从www.sencha.com/products/sencha-cmd/download下载并安装 Sencha Cmd。Sencha Cmd 负责创建应用程序,并生成、构建和编译 Sass 和 Compass 以生成应用程序的 CSS。安装 Sencha Cmd 后,sencha命令也将从终端应用程序中可用。

下载完 Ext JS SDK 后,将其解压到 Apache Xampp 的htdocs文件夹内。一旦我们启动 Apache 服务器,我们就能从本地环境执行 Ext JS 示例:

下载 Ext JS 和 Sencha Cmd

离线文档

在使用 Ext JS 进行开发时,我们会经常查阅文档。每当本书提到 Ext JS 组件的名称时,建议您查阅文档并查看相关信息。Ext JS 文档可在docs.sencha.com/extjs/5.0/找到。它包含指南(在深入本书之前,我们强烈建议您花些时间阅读指南,因为指南提供了关于框架的基本知识),以及指向博客文章和文档本身的链接。由于我们会经常查阅,我们建议您也本地安装文档。为此,请访问docs.sencha.com/,打开Sencha Guides菜单,并选择离线文档链接,如下截图所示:

离线文档

将文档解压到 Xampp 的htdocs文件夹内,并访问您的localhost,如下截图所示:

离线文档

注意

一个包含逐步说明的视频教程,用于设置 Ext JS 的开发环境,可在 youtu.be/B43bEnFBRRc 找到。

集成开发环境(IDE)

您可以使用任何您偏好的 IDE 或编辑器来使用 Ext JS 进行开发。以下是一些非常受欢迎的编辑器:Sublime Text、Atom、Eclipse(如果您是 Java 开发者)、Netbeans、Visual Studio(如果您是 C# 开发者)、Notepad++ 和 WebStorm 等。

如果您正在寻找自动完成功能,您可以使用 Sencha Complete(付费)中的 Sencha Eclipse 插件,该插件可在 www.sencha.com/products/complete/ 找到,或者您可以使用 WebStorm(也是付费的),可在 www.jetbrains.com/webstorm/ 找到。

此外,还有 Sencha Architect(也具有自动完成功能)。它是一个所见即所得(WYSIWYG)编辑器,并且是一个优秀的 Sencha 工具,可以与您偏好的 IDE(用于开发应用的客户端代码)一起使用。

请随意使用您最舒适的编辑器或 IDE 来开发本书的源代码!

摘要

在本章中,我们快速概述了 Ext JS 并提供了一些有用的参考资料,以获取理解本书中将使用的术语和组件所需的基本知识。

在下一章中,我们将介绍本书中将使用的应用,并且我们也将使用 Sencha Cmd 来创建它。

第二章. 入门

在本书中,我们将深入探索 Sencha Ext JS 5 世界,并研究现实世界的示例。我们还将从头开始构建一个完整的应用程序,从线框阶段直到在生产环境中的部署。

在整本书中,我们将开发一个用于管理 DVD 租赁店 的应用程序。在本章中,我们将介绍该应用程序及其功能。你还将学习如何组织应用程序的文件,这些文件将在本书的章节中构建。本章还将展示应用程序的草图(线框)以及如何开始组织屏幕(这是一个非常重要的步骤,一些开发者会忘记做)。在本章中,我们将涵盖以下内容:

  • 通过安装所需的软件来准备开发环境

  • 展示应用程序及其功能

  • 为每个屏幕创建草图/线框

  • 使用 Sencha Cmd 创建应用程序的结构

  • 创建加载页面(启动画面)

准备开发环境

我们将要开发的应用程序具有一个非常简单的架构。我们将在前端使用 Ext JS 5,它将通过 Ajax/JSON 与后端模块通信,然后该模块将与数据库通信。

以下图表概括了前面的段落:

准备开发环境

后端模块将使用 PHP 开发。如果你不知道 PHP,无需担心。我们将使用非常基础的代码(无框架),并将重点放在需要在服务器端实现的编程逻辑上。这样,你可以使用任何其他编程语言,如 Java、ASP.NET、Ruby、Python 或任何其他(支持以 JSON 或 XML 格式交换数据,因为这是 Ext JS 使用的通信格式)来应用相同的逻辑。对于数据库,我们将使用 MySQL。我们还将使用 Sakila 示例模式(dev.mysql.com/doc/sakila/en/),这是一个免费的 MySQL 示例数据库,非常适合演示如何在数据库表中执行 创建、读取、更新和删除/销毁CRUD)操作,以及使用更复杂的操作,如视图和存储过程(我们将学习如何使用 Ext JS 处理所有这些信息)。

注意

如果你是一名 Java 开发者,你可以在goo.gl/rv76E2goo.gl/nNIRuQ找到一些如何将 Java 与 Ext JS 集成的示例代码。

此外,我们还需要安装 Sencha Cmd(我们已经在第一章中安装了 Sencha Cmd,Sencha Ext JS 概述)。然而,我们仍然需要执行一些额外的步骤来配置它。一旦配置完成,我们就能创建应用程序结构,定制主题,并制作生产版本。Sencha Cmd 需要 Ruby 与 1.8 或 1.9 版本兼容(2.x 版本将无法使用)。我们还需要安装 Apache Ant(由于 Apache Ant 是用 Java 构建的,因此我们还需要在计算机上安装和配置 Java)。

在我们完成应用程序的实现后,我们将定制主题,因此我们需要安装 Ruby(1.8 或 1.9)以及 Sass 和 Compass 宝石。

为了部署应用程序,我们需要一个 Web 服务器。如果您尚未在计算机上安装任何 Web 服务器,请不要担心。在这本书中,我们将使用 Xampp 作为默认的 Web 服务器。

我们还需要一个浏览器来运行我们的应用程序。推荐使用 Firefox(带 Firebug)或 Google Chrome。

如果您之前提到的任何软件或技术尚未安装在您的计算机上,请不要担心。为了总结在开始有趣的工作之前我们需要安装的所有工具和软件,以下是一个包含下载链接和安装说明的列表(所有项目均适用于 Windows、Linux 和 Mac OS):

当然,我们还需要 Ext JS SDK 和 Sencha Cmd,这些我们在第一章中下载并安装了,Sencha Ext JS 概述

注意

为了帮助你配置所需的开发环境,以便能够从本书创建应用程序,这里有一个逐步演示的视频(逐步完成 Windows 环境——Linux 和 Mac OS 的设置非常相似):youtu.be/B43bEnFBRRc

展示应用程序及其功能

我们将在整本书中开发的应用程序与其他你可能习惯实现的其他 Web 系统非常相似。我们将实现一个 DVD 租赁店管理应用程序(这就是为什么使用 Sakila 示例数据库)。应用程序的一些功能包括安全管理(能够在应用程序中管理用户及其权限)、管理演员、电影、库存和租赁信息。

Ext JS 将帮助你实现目标。它提供了美观的组件、完整的架构、组件重用的可能性(减少了我们的工作量),以及一个非常完整的数据包(使得连接到服务器端和发送、检索信息变得更容易)。

我们将把应用程序分成模块,每个模块将负责应用程序的一些功能。在这本书的每一章中,我们将实现一个模块。

应用程序由以下部分组成:

  • 启动屏幕(这样在应用程序仍在启动时,用户就不需要看到空白屏幕)

  • 主屏幕

  • 登录屏幕

  • 用户管理屏幕

  • MySQL 表管理(用于类别和组合框值——静态数据)

  • 内容管理控制

对于前面列表中提到的每个模块和屏幕,我们将创建原型,这样我们就可以规划应用程序的工作方式。在这里,你将了解它们中的每一个。

启动屏幕

我们的应用程序将有一个启动屏幕,这样在应用程序还在加载初始化之前所需的文件和类时,用户就不需要看到空白页面。以下是一个展示启动屏幕的截图:

启动屏幕

我们将在本章后面介绍这个屏幕的实现。

登录屏幕

在应用程序完全加载后,用户将看到的第一个屏幕是登录屏幕。用户将能够输入用户名密码。还有一个多语言组合框,用户可以选择系统的语言(多语言功能)。然后,我们有取消提交按钮,如下面的截图所示:

登录屏幕

取消按钮将重置登录表单,而提交按钮将触发一个事件,该事件将创建一个 Ajax 请求并将用户凭据发送到服务器进行认证。如果用户认证成功,则显示主屏幕;否则,显示错误消息。

我们将在第三章,登录页面 中介绍登录屏幕的实现。

主屏幕

应用程序的一般思路是使用边框布局来组织主屏幕。边框布局分为五个区域:北、南、东、西和中心,其中除了东区域外,以下图中都进行了演示:

主屏幕

中心区域,我们将有一个标签面板,每个标签代表应用程序的一个屏幕(每个屏幕将有自己的布局)——只有第一个标签不能关闭(主页标签)。在北区域,我们将有一个包含应用程序名称(DVD 租赁店管理器)、多语言组合框(如果用户想要更改应用程序的当前语言)和注销按钮的页眉。在南区域,我们将有一个包含版权信息的页脚(或者它可以是实施项目的公司或开发者的名称)。在西区域,我们将有一个动态菜单(我们将实现用户控制管理)。菜单将是动态的,并将根据用户在应用程序中的权限进行渲染。

主屏幕将看起来大致如下所示:

主屏幕

我们将在第四章,注销和多语言功能 中介绍主屏幕的实现以及多语言和注销功能。在第五章,高级动态菜单 中,我们将介绍如何生成动态菜单。

用户管理

在用户控制管理中,用户将能够创建新用户和新组,并为用户分配新角色。用户将能够控制系统权限(哪些用户可以看到系统中的哪些模块)。这是创建/编辑用户页面的样子:

用户管理

我们将在第六章,用户管理 中介绍用户管理的实现。

MySQL 表管理

每个系统都有被认为是静态数据的信息,例如电影类别、电影语言、组合框选项等。对于这些表,我们需要提供所有 CRUD 选项和过滤选项。此模块的屏幕将非常类似于MySQL Workbench中的编辑表数据选项,如下面的截图所示:

MySQL 表管理

用户将能够编辑网格中的行数据,类似于在 MS Excel 中可以执行的操作。一旦用户完成更改,他们可以点击保存更改按钮,将所有修改后的数据保存到服务器和数据库中。以下是如何查看浏览器窗口视图:

MySQL 表管理

我们将在第七章静态数据管理中介绍这个模块的实现,静态数据管理

内容管理控制

在本模块中,用户将能够查看和编辑系统中的核心信息。由于我们将在本模块中处理的数据库表中的大多数都与其他表有关联,因此信息的编辑将更加复杂,涉及主从关系。通常,我们将信息以数据网格(列表或表格)的形式呈现给用户,信息的添加将以弹出窗口内显示的形式进行。

还非常重要的一点是要记住,大多数模块的屏幕将具有类似的功能,并且由于我们将构建一个包含许多屏幕的应用程序,因此设计系统以便尽可能多地重用代码,使系统易于维护并易于添加新功能和能力是非常重要的。以下屏幕展示了本节中讨论的功能:

内容管理控制

当点击添加编辑时,将打开一个新弹出窗口来编辑信息,如下所示:

内容管理控制

我们将在第八章内容管理和第九章添加额外功能中介绍这个模块的实现,添加额外功能

图表

在图表模块中,我们将使用 Ext JS 创建图表。对于相同的图表信息,用户将能够生成不同类型的图表。用户还可以将图表导出为图像、SVG 或 PDF,如下所示:

图表

我们将在第九章添加额外功能中介绍这个模块的实现,添加额外功能

使用 Sencha Cmd 创建应用程序

让我们开始动手编写代码。我们将要做的第一件事是使用 MVC 结构创建应用程序。从现在起,我们将称之为 Sencha 命令(Sencha Cmd),它为我们提供了自动创建应用程序结构的功能。利用 Sencha Cmd 不仅因为它为我们根据 MVC 架构创建应用程序结构,而且还因为它提供了我们在软件上线和定制主题时需要的所有文件——我们将在后面的章节中了解更多关于这一点。

简单谈谈 MVC

MVC代表模型-视图-控制器。它是一种软件架构模式,将信息的表示与用户的交互分离。模型表示应用程序数据,视图表示数据的表示输出(表单、网格、图表),控制器协调输入,将其转换为模型或视图的命令。

Ext JS 支持 MVC模式,这是一种模型-视图-控制器模式(作为架构选项之一,它还提供了模型-视图-视图模型(MVVM),我们将在后面讨论)。模型是我们想要在应用程序中操作的数据的表示,是数据库中表的表示。视图是我们创建的所有组件和屏幕,用于管理模型的信息。由于 Ext JS 是事件驱动的,所有视图实例在用户与之交互时都会触发事件,控制器可以配置为监听从视图引发的事件,开发者可以实施自定义处理程序来响应这些事件。控制器还可以将命令重定向到模型(或存储)或视图。在 Ext JS 中,存储与在服务器端技术中使用的数据访问对象(DAO)模式非常相似(在第一章

创建应用程序

我们将在 Xampp 目录的htdocs文件夹中创建应用程序。我们的应用程序将命名为masteringextjs

在我们开始之前,让我们看看htdocs文件夹的样貌:

创建应用程序

我们仍然有原始的 Xampp 文件,以及 Ext JS 5 SDK 文件夹和 Ext JS 5 文档文件夹。

下一步是使用 Sencha Cmd 为我们创建应用程序。为此,我们需要打开操作系统附带的终端应用程序。对于 Linux 和 Mac OS 用户,这将是一个终端应用程序。对于 Windows 用户,它是命令提示符应用程序。

这里是我们将要执行的步骤:

  1. 首先,我们需要将当前目录更改为 Ext JS 目录(在本例中为 htdocs/ext-5.0.0)。

  2. 然后,我们将使用以下命令:

    sencha generate app Packt ../masteringextjs
    
    

sencha generate app 命令将在 htdocs 文件夹内创建 masteringextjs 目录,并包含 ExtJS 所需的 MVC 架构所需的必要文件结构。Packt 是我们应用程序的命名空间名称,这意味着我们创建的每个类都将以 Packt 开头,例如,Packt.model.ActorPack.view.Login 等。命令传递的最后一个参数是应用程序将被创建的目录。在这种情况下,它位于 htdocs 文件夹内的 masteringextjs 文件夹中。

注意

命名空间用于变量和类的范围,以确保它们不是全局的,并深入定义它们的嵌套结构。Sencha 在 goo.gl/2iLxcn 有关于命名空间的好文章。

命令执行完成后,我们将得到以下截图所示的内容:

创建应用程序

注意

本书中的源代码是用 Ext JS 5.0 编写的(因此您将看到带有 5.0 版本的截图),并在发布后升级到 5.1。因此,一旦您下载其源代码,它将与 5.1 版本兼容。从 5.0 升级到 5.1 的源代码没有任何影响。

但为什么我们需要创建这样的项目结构呢?以下是由 Ext JS 应用程序使用的结构:

创建应用程序

注意

关于 Sencha generate app 命令的更多信息,请参阅 docs.sencha.com/cmd/5.x/extjs/cmd_app.html

让我们看看每个文件夹的作用。

首先,我们有 app 文件夹。这是我们创建应用程序所有代码的地方。在 app 文件夹内,我们还可以找到以下文件夹:controllermodelstoreview。我们还可以找到 Application.js 文件。让我们详细了解一下它们。在 model 文件夹中,我们将创建所有代表 Model 的文件,Model 是一个 Ext JS 类,它代表一组字段,这意味着它是我们应用程序管理的对象(演员、国家、电影等)。它与服务器端上的类类似,只是具有类的属性以及用于表示数据库表的 getter 和 setter 方法。

store文件夹中,我们将创建所有的Store类,这些类是模型实例集合的缓存。store文件夹也具有类似于 DAO在服务器端语言中用于在数据库上执行 CRUD 操作的类的功能。由于 Ext JS 不直接与数据库通信,因此使用Store类与服务器端或本地存储(使用 Proxy)进行通信。Proxies 由 Store 或 Model 实例用于处理 Model 数据的加载和保存,这是我们配置如何与服务器通信(使用 Ajax 和使用 JSON 或 XML 格式化数据,以便客户端和后端能够相互理解)的地方。

view文件夹中,我们将创建所有的view类,也称为用户界面组件UI 组件),例如网格、树、菜单、表单、窗口等。

最后,在controller文件夹中,我们可以创建处理由组件(由于组件的生命周期或用户与组件的某些交互)触发的事件的类。我们始终需要记住 Ext JS 是事件驱动的,在Controller类中,我们将控制这些事件并更新任何 Model、View 或 Store(如果需要)。触发的一些事件示例包括按钮的点击或鼠标悬停,网格行的itemclick事件等。

注意

我们也可以在view文件夹内创建ViewController类。这在我们使用 MVVM 模式时适用,我们将在下一章中更详细地讨论这一点。

MVC 的app文件夹结构已经创建。现在,我们将把 Ext JS SDK(extjs文件夹)复制到masteringExtjs文件夹中,我们还将创建一个名为app.js的文件。我们将在本章后面编辑此文件。

我们还有一个Application.js文件。这是我们的应用程序的入口点。我们稍后会回到这个话题。

masteringextjs目录包含一些额外的文件:

  • app.js:此文件从Application.js文件继承代码。这是应用程序的入口点。这是由 Ext JS 调用来初始化应用程序的文件。我们应该避免更改此文件app.json:这是应用程序的配置文件。在此文件中,我们可以添加额外的 CSS 和 JS 文件,这些文件应与应用程序一起加载,例如图表和特定地区的配置。我们将在本书中对此文件进行一些更改。

  • bootstrap.cssbootstrap.jsonbootstrap.js:由 Sencha Cmd 创建,这些文件不应该被编辑。CSS 文件包含了应用程序使用的主题的导入(默认情况下是所谓的Neptune 主题)。在构建完成后,这些文件的内容会更新为 CSS 定义,JavaScript 文件将包含在执行前需要加载的依赖项、自定义xtypes和其他类系统功能。

  • build.xml:Sencha Cmd 使用 Apache Ant(ant.apache.org/),这是一个用于构建 Java 项目的 Java 工具。Ant 使用一个名为build.xml的配置文件,其中包含所有必需的配置和命令来构建项目。Sencha Cmd 使用 Ant 作为后台构建 Ext JS 应用程序的引擎(而我们需要简单地使用命令)。这就是为什么我们需要安装 Java SDK 来使用一些 Sencha Cmd 功能的原因。

  • index.html:这是我们项目的索引文件。这是当执行我们的应用程序时浏览器将渲染的文件。在这个文件中,我们将找到bootstrap.js文件的导入。我们应该避免在这个文件中做任何更改,因为当我们构建应用程序时,Sencha Cmd 将在build文件夹中生成一个新的index.html文件,丢弃我们对index.html文件所做的所有更改。如果我们需要包含一个 JS 或 CSS 文件,我们应该在app.json文件中定义这个文件。

  • ext:在这个文件夹中,我们可以找到所有 Ext JS 框架文件(ext-allext-all-debugext-dev),其源代码,以及包含我们的应用程序和主题相关包的packages文件夹等。

  • 覆盖:当我们创建应用程序时,这个文件夹是空的。我们可以添加我们项目需要的任何类覆盖和自定义。

  • :在这个文件夹中,我们可以创建自己的包。一个包可以是一个新的主题,例如。其概念类似于 Ruby 中的gem或 Java 和.NET 中的自定义 API(例如,在 Java 项目中使用 Apache Ant,我们需要包含 Apache Ant 的jar文件)。

  • 资源:在这个文件夹中,我们将放置我们应用程序的所有图片。我们也可以放置其他 CSS 文件和字体文件。

  • sass:在这个文件夹中,我们可以找到一些用于创建主题的 Sass 文件。我们应用程序的任何自定义 CSS 都将创建在这个文件夹中。

现在让我们获取第一手知识吧!我们将现在探索之前在开发我们的应用程序过程中描述的一些概念,以便更好地理解它们。

使用 watch 命令查看更改

Sencha Cmd 还有一个在开发 Ext JS 应用程序时非常有用的命令,那就是watch命令。在本书开发应用程序的过程中,我们将一直使用这个命令。

让我们执行这个命令并看看它会做什么。首先,我们需要将目录更改为masteringextjs文件夹(这是我们使用 Sencha Cmd 生成的应用程序文件夹)。然后,我们可以执行sencha app watch命令。以下截图展示了这个命令的输出:

使用 watch 命令查看更改

这个命令会查找 Ext JS 应用程序文件夹内部的所有更改。如果我们创建了一个新文件或更改了任何文件,这个命令就会知道已经进行了更改,并将生成一个应用程序构建。

小贴士

您可以最小化终端应用程序,让命令在后台执行。如果我们关闭终端,命令将不再活跃,我们必须再次执行它。

它还将启动一个位于 http://locahost:1841 的网络服务器,我们可以像以下截图所示那样访问我们的应用程序:

使用 watch 命令查看更改

因此,这就是使用 Sencha Cmd 创建的应用程序的外观。Sencha Cmd 还在 app 文件夹内部创建了一些文件,我们可以根据需要更改它们。我们将在下一章中介绍这些文件。

我们也可以通过访问 http://localhost/masteringextjs 来从 Xampp 的 localhost URL 执行应用程序,如下所示:

使用 watch 命令查看更改

小贴士

通过 http://localhost/masteringextjs 的输出与通过 http://locahost:1841 访问的输出完全相同。您可以使用任何一个。但请注意,http://locahost:1841 只在我们使用 sencha app watch 命令时才会活跃。由于我们将使用 PHP 并应用一些其他配置,我们将全书使用 http://localhost/masteringextjs

在我们的应用程序中应用第一次更改

我们知道 Sencha Cmd 为我们创建了一些文件,但我们不希望那个屏幕是用户首先看到的。我们希望用户先看到登录屏幕,然后进入主屏幕。

要实现这一点,我们需要在 app.js 文件中进行一些更改。如果我们打开文件,我们会看到以下内容:

Ext.application({
    name: 'Packt',

    extend: 'Packt.Application',

 autoCreateViewport: 'Packt.view.main.Main'
});

我们将更改前面高亮的代码,改为以下代码:

autoCreateViewport: false

我们将在这个文件中进行的唯一更改就是,我们应该避免更改它。

这行代码的作用是在应用程序初始化后自动创建 Packt.view.main.Main 组件,但我们不希望这样。我们希望首先显示启动屏幕,然后是登录屏幕。这就是为什么我们要求应用程序不要自动渲染视口。

注意,运行 sencha app watch 命令的终端将输出几行,这意味着应用程序将被刷新,开发构建更新。每次我们进行更改并保存时,都会发生这种情况。

如果我们刷新浏览器,我们应该看到一个空页面。这意味着我们可以开始开发我们的 DVD 租赁店应用程序。

理解 Application.js 文件

如果我们打开 app/Application.js 文件,它看起来是这样的:

Ext.define('Packt.Application', { // #1
    extend: 'Ext.app.Application',

    name: 'Packt', // #2

    views: [ // #3

    ],

    controllers: [ // #4
        'Root'
    ],

    stores: [ // #5

    ],

    launch: function () { // #6
        // TODO - Launch the application
    }
});

app.js 文件从 Application.js 文件继承了应用程序的所有行为。此文件用作应用程序的入口点。

在前面代码的第一行,我们有 Ext.application 的声明(#1)。这意味着我们的应用程序将有一个单页应用(en.wikipedia.org/wiki/Single-page_application),并且应用程序的父容器将是视口。视口是一个特殊的容器,代表在 HTML 页面的 <body> 标签内渲染的可视应用程序区域(<body></body>)。

Ext.application 内部,我们还可以声明应用程序使用的 views#3)、controllers#4)和 stores#5)。随着我们为项目创建新的类,我们将在该文件中添加这些信息。

我们需要声明应用程序的 name 属性,这将作为应用程序的命名空间(#2)。在这种情况下,Sencha Cmd 使用了我们在 sencha generate app 命令中使用的命名空间。

我们还可以在 Ext.application 内部创建一个 launch 函数(#6)。这个函数将在所有应用程序的控制器初始化之后被调用,这意味着应用程序已经完全加载。因此,这个函数是实例化我们的主视图的好地方,在我们的例子中,这将是我们登录屏幕。

提示

我们在使用 Ext.application 时是否需要使用 Ext.onReady

答案是否定的。我们只需要使用其中一个选项。根据 Ext JS API 文档,Ext.application 在页面准备就绪后加载 Ext.app.Application 类,并使用给定的配置启动它,而 Ext.onReady 添加一个新监听器,在所有必需的脚本完全加载时执行

如果我们查看 Ext.application 的源代码,我们有:

Ext.application = function(config) {
    Ext.require('Ext.app.Application');
    Ext.onReady(function() {
        new Ext.app.Application(config);
    });
};

这意味着 Ext.application 已经调用了 Ext.onReady,所以我们不需要重复调用它。

因此,当您有一些要显示的组件,而这些组件不在 MVC 架构中(类似于 jQuery 的 $(document).ready() 函数)时,请使用 Ext.onReady;当您正在开发 Ext JS MVC 应用程序时,请使用 Ext.application

以下图表展示了 Ext JS 应用程序启动期间执行的所有高级步骤。一旦执行了这些步骤,应用程序就完全加载了:

理解 Application.js 文件

既然我们已经知道了 Ext JS 应用程序的初始化方式,我们就可以开始构建我们的应用程序了。

创建加载页面

当与大型 Ext JS 应用程序一起工作时,在加载应用程序时出现短暂的延迟是正常的。这是因为 Ext JS 正在加载所有必需的类,以便使应用程序运行,同时,所有用户看到的是一个空白的屏幕,这可能会对他们造成烦恼。解决这个问题的非常常见的解决方案是有一个加载页面,也称为启动屏幕。

因此,让我们在我们的应用程序中添加一个类似于以下截图所示的启动屏幕:

创建加载页面

首先,我们需要了解这个启动屏幕是如何工作的。在用户加载应用程序后,启动屏幕将被显示。应用程序将在加载所有必需的类和代码的同时显示启动屏幕,以便应用程序可以被使用。

我们已经知道当应用程序准备好使用时,它会调用launch函数。所以我们知道我们需要在launch方法中移除启动屏幕。现在的问题是:在Ext.application的哪个位置可以调用启动屏幕?答案是init函数内部。init函数在应用程序启动时被调用,这给了所有必需的代码加载一些时间,然后调用launch函数。

现在我们知道了启动屏幕是如何工作的,让我们来实现它。

Ext.application内部,我们在launch函数声明之后实现一个名为init的函数:

init: function () {
    var me = this; // #1
    me.splashscreen = Ext.getBody().mask( // #2
        'Loading application', 'splashscreen'
    ); 
}

我们需要做的只是将一个mask方法(#2)应用到应用程序的 HTML 主体中(Ext.getBody())。这就是为什么我们调用mask方法,传递加载信息(Loading Application),并应用一个 CSS 样式,这将是一个加载的GIF文件,它已经是 Ext JS CSS 的一部分(splashscreen)。mask方法将返回一个Ext.dom.Element类,我们稍后需要对其进行操作(从 HTML 主体中移除mask方法),因此我们需要保留对这个Ext.dom.Element类的引用,并将这个引用作为Ext.application的一部分存储为变量(me.splashscreen)。me变量是对this#1)的引用,它指向Ext.application本身。

仅使用init方法的代码,我们将有一个如下所示的加载屏幕:

创建加载页面

如果这对你来说足够了,那就没问题。但让我们更进一步,定制加载屏幕,添加一个标志图像,使其看起来像这个主题的第一张图片,这是我们最终的输出。

要将图片添加到我们的启动屏幕中,我们需要创建一个 CSS 样式。我们可以创建一个 CSS 文件并将其包含在我们的index.html文件中,但我们将遵循最佳实践,在sass文件夹内创建一个Sass文件。

Ext JS 有一种更好的方式来处理自定义 CSS 样式。当我们完成应用程序的实现后,我们想要定制主题,并想要创建一个生产构建。生产构建只包含执行应用程序所需的 Ext JS SDK 源代码,以及我们的应用程序源代码。此代码将被混淆和优化,以便用户可以下载一个最小尺寸的文件。生产构建也会对 CSS 文件做同样的事情;它将优化并添加执行应用程序所需的 Ext JS 组件的 CSS。当然,我们希望我们创建的任何自定义 CSS 也得到优化。

要做到这一点,我们将在 sass/etc 文件夹中使用 Sass 创建自定义 CSS(sass-lang.com/)。因此,让我们在 sass/etc 文件夹中创建一个名为 all.scss 的文件。

在资源内部,我们还将创建一个包含 Packt 标志图像的 images/app 文件夹(你可以从本书的源代码中复制 Packt 标志图像)。

创建这些文件后,我们的应用程序结构将如下所示:

创建加载页面

all.scss 文件将看起来像这样:

.x-mask.splashscreen {
  background-color: white;
  opacity: 1;
}

.x-mask-msg.splashscreen,
.x-mask-msg.splashscreen div {
  font-size: 16px;
  font-weight: bold;
  padding: 30px 5px 5px 5px;
  border: none;
  background: {           // #1
    color: transparent;   // #2
    position: top center; // #3
  };
}

.x-message-box .x-window-body .x-box-inner {
  min-height: 110px !important;
}

.x-splash-icon {
  background-image: url('images/app/packt-logo.png') !important;
  margin-top: -30px;
  height: 70px;
}

这是一段纯 CSS 代码,除了行 #1#2#3,这是 Sass 代码。

注意

如果你不太熟悉 Sass,它是一种编写 CSS 代码的新方法。Sass 使用 不要重复自己DRY)原则,这意味着你可以使用变量和嵌套语法(如我们在 #1 中使用的)以及导入其他 Sass 文件(允许你创建模块化 CSS),以及其他功能。Ext JS 引擎主题使用 Sass 和 Compass(一个 Sass 框架)。Sass 是 Less 的替代品。要了解更多关于 Sass 的信息,请访问 sass-lang.com/

#1#2#3 将与以下内容相同:

background-color: transparent;
background-position: top center;

上述代码是在编译 Sass 文件后生成的 CSS 代码。

注意

当 Sencha Cmd 进行构建时,我们的 all.scss 文件中的代码将被添加到为我们的应用程序生成的单个 CSS 文件中,该文件包含 Ext JS CSS 代码以及我们的代码。

现在,让我们回到 Application.js 文件,并继续向 init 函数中添加一些代码。

我们在已有的代码之后添加以下代码:

me.splashscreen.addCls('splashscreen');

我们将向加载 <div> 标签添加一个新的 CSS 样式。请注意,以下来自 app.css 的样式将被应用:.x-mask.splashscreen.x-mask-msg.splashscreen div。这将使背景变为白色而不是灰色,并且它还将改变 “加载应用程序” 消息的字体。

生成的 HTML 将如下所示:

创建加载页面

现在,我们将在 init 函数中添加以下代码:

Ext.DomHelper.insertFirst(Ext.query('.x-mask-msg')[0], {
     cls: 'x-splash-icon'
});

上述代码将搜索包含 .x-mask-msg 类的第一个 <div> 标签(Ext.query('.x-mask-msg')[0]),并将一个新的 <div> 标签作为子标签添加,其类为 x-splash-icon,它将负责在加载消息上方添加标志图像。

生成的 HTML 将如下所示:

创建加载页面

执行上述代码后,我们将得到本主题开头截图所示的输出。

现在我们已经显示了启动屏幕。我们需要在所有应用程序需要的代码加载完毕后,在 launch 函数中移除启动屏幕;否则,加载信息将无限期地显示在那里!

要移除启动屏幕,我们只需要在 launch 函数中添加以下代码,该代码涉及从 HTML 身体中移除遮罩:

Ext.getBody().unmask();

然而,突然移除遮罩并不好,因为用户甚至看不到加载信息。在应用程序准备就绪后,除了移除遮罩,我们还可以给用户 2 秒钟的时间来查看加载信息:

var task = new Ext.util.DelayedTask(function() { // #1
     Ext.getBody().unmask(); // #2
});

task.delay(2000); // #3

为了做到这一点,我们使用DelayedTask类(#1),这是一个在给定毫秒数(#3)超时后执行函数的机会的类。因此,在以下任务的情况下,我们在 2 秒(2,000 毫秒)的超时后从 HTML 主体中移除遮罩(#2)。

如果我们现在测试输出,它工作得很好,但对于用户来说仍然不好。如果我们能添加一个遮罩动画会更好。所以,我们将添加一个淡出动画(它动画元素的透明度——从不透明到透明),动画之后,我们将移除遮罩(在Ext.util.DelayedTask函数内部)。以下代码是本段提供的解释的演示:

me.splashscreen.fadeOut({
    duration: 1000,
    remove:true
});

执行此代码后,请注意加载信息仍在显示。我们需要分析生成的 HTML 以找出原因。

在我们调用fadeOut函数之前,这是加载信息的 HTML:

创建加载页面

在我们调用fadeout函数之后,HTML 将是以下内容:

创建加载页面

只有具有splashscreen类的第一个<div>标签被淡出。我们还需要淡出包含标志和加载信息的具有x-mask-msg splashscreen类的<div>标签。为此,我们可以使用以下方法,该方法将获取splashscreen节点的下一个兄弟节点,如下所示:

me.splashscreen.next().fadeOut({
    duration: 1000,
    remove:true
});

输出将是一个用户可以看到的愉快动画。同时请注意,splashscreen <div>标签已从生成的 HTML 中删除,如下所示:

创建加载页面

launch函数的完整代码如下:

launch: function () {
    var me = this;

    var task = new Ext.util.DelayedTask(function() {

        //Fade out the body mask
        me.splashscreen.fadeOut({
            duration: 1000,
            remove:true
        });

        //Fade out the icon and message
        me.splashscreen.next().fadeOut({
            duration: 1000,
            remove:true,
            listeners: { // #1
                afteranimate: function(el, startTime, eOpts ){//#2
                    console.log('launch') // #3
                }
            }
        });
    });

    task.delay(2000);
},

提示

下载示例代码

您可以从您在www.packtpub.com的账户下载示例代码文件,以获取您购买的所有 Packt Publishing 书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

为了使我们的启动屏幕更加美观,我们将监听(#1fadeOut方法的afteranimate事件(#2),以便我们可以显示我们应用程序的初始组件。我们将展示一个将在下一章实现的登录屏幕。现在,我们将添加一个控制台消息(#3),以便知道我们需要在哪里调用初始组件。在 Internet Explorer 中,console.log将不起作用;相反,您可以使用window.console.log

注意

注意,我们用来显示加载信息遮罩并移除它的所有代码都是Ext.dom.Element类的一部分。这个类封装了一个文档对象模型DOM)元素,我们可以使用类的方法来管理它。这个类是Ext Core库的一部分,它是 Ext JS 框架的基础之一。

摘要

在本章中,我们深入探讨了本书各章节中将实现的应用程序。我们还涵盖了创建此应用程序的开发环境所需的所有要求。你学习了如何创建 Ext JS MVC 应用程序的初始结构。

你还通过示例学习了如何创建启动屏幕(也称为加载屏幕),使用Ext.dom.Element类操作 DOM。你学习了启动 Ext JS 应用程序的步骤,以及从Ext.application中学习initlaunch方法之间的区别。我们让Application.js准备好显示其第一个屏幕,这将是一个登录屏幕,你将在下一章中学习如何实现它。

第三章:登录页面

对于我们想要通过用户提供的凭据来识别和验证用户以控制对系统访问的应用程序,登录页面是非常常见的。一旦用户登录,我们可以跟踪用户执行的操作。我们还可以限制对系统某些功能和屏幕的访问,这些功能和屏幕我们不希望特定用户或特定用户组访问。

在本章中,我们将涵盖:

  • 创建登录页面

  • 在服务器上处理登录页面

  • 密码字段中添加大写锁定警告信息

  • 按下Enter键时提交表单

  • 在发送到服务器之前加密密码

登录界面

登录窗口将是我们在本项目中要实现的第一视图。我们将逐步构建它,如下所示:

  • 用户将输入用户名和密码进行登录

  • 客户端验证(登录所需的用户名和密码)

  • 按下Enter键提交登录表单

  • 在发送到服务器之前加密密码

  • 密码大写锁定警告(类似于 Windows 操作系统)

  • 多语言功能

除了我们将在下一章中实现的多语言功能外,我们将在此主题的其余部分实现所有其他功能。因此,在实现结束时,我们将拥有一个如下所示的登录窗口:

登录界面

让我们开始吧!

创建登录界面

app/view目录下,我们将创建一个新的文件夹来组织所有与登录屏幕相关的源代码,命名为login。在login文件夹内,我们还将创建一个名为Login.js的新文件。在这个文件中,我们将实现用户将在屏幕上看到的全部代码。

view/login/Login.js内部,我们将实现以下代码:

Ext.define('Packt.view.login.Login', { // #1
    extend: 'Ext.window.Window',       // #2

    xtype: 'login-dialog',             // #3

    autoShow: true,                    // #4
    height: 170,                       // #5
    width: 360,                        
    layout: {
        type: 'fit'                    // #7
    },
    iconCls: 'fa fa-key fa-lg',        // #8
    title: 'Login',                    // #9
    closeAction: 'hide',               // #10
    closable: false,                   // #11
    draggable: false,                  // #12
    resizable: false                   // #13
});

在第一行(#1),我们有类的定义。要定义一个类,我们使用Ext.define,这是一个Ext单例类的define方法调用,它接受两个参数:类名(#1)和包含类配置的对象字面量(#2#13)。

我们还需要注意类的名称。这是 Sencha 在 Ext JS MVC 项目中建议的公式:应用程序命名空间 + 包名 + JS 文件名。在上一章中,我们将命名空间定义为Packt(我们传递给sencha generate app命令的应用程序名称)。例如,如果我们打开由 Sencha Cmd 创建的现有文件(如app/view/main/Main.js文件),我们会注意到类的名称以Packt开头。因此,我们将在此书中创建的所有类都将以命名空间Packt开头。

我们正在为这个项目创建一个视图,因此我们将在view文件夹下创建 JS 文件。为了组织目的,我们创建了一个名为login的子文件夹。然后,我们创建的文件名为Login.js;因此,我们将丢弃.js,只使用Login作为视图的名称。将所有这些放在一起,我们得到Packt.view.login.Login,这将是我们的类名。非常重要的一点是,类名必须遵循如上所述的目录布局;否则,我们可能会在代码中得到错误,表示 Ext JS 没有找到该类。以下截图显示了项目目录布局和类名之间的依赖关系:

创建登录屏幕

然后,我们说login类将扩展自Window类(#2)。回顾我们在第一章中介绍的内容,Sencha Ext JS 概述,我们可以在 Ext JS 中使用继承。login类将从Window类继承行为(它是Component类的子类)。window组件代表一个在浏览器中渲染为中心的弹出窗口。

提示

如需了解更多关于窗口组件的信息,请访问docs.sencha.com/extjs/5.0.0/apidocs/#!/api/Ext.window.Window。有关继承的更多详细信息,请阅读goo.gl/v4bmq8

我们还分配了这个类:xtype#3)。xtype类是一个较短的名称,可以用它来实例化类,而不是使用它的完整名称。我们还可以使用配置alias而不是xtype

扩展自组件的类的alias总是以widget开头,后面跟着我们想要分配的aliasxtype类。如果我们想使用alias配置而不是xtype,我们可以使用alias: 'widget.login-dialog'而不是xtype: 'login-dialog'。结果将相同;这只是个人偏好的问题。

xtypealias的命名约定是小写。还重要的是要记住,别名在应用程序中必须是唯一的。在这种情况下,我们想将xtypelogin分配给这个类,以便以后我们可以使用它的alias(与xtype相同)来实例化这个相同的类。例如,我们可以以五种不同的方式实例化Login类:

  • 选项 1:使用类的完整名称,这是最常用的方法:

    Ext.create('Packt.view.login.Login');
    
  • 选项 2:在Ext.create方法中使用alias

    Ext.create('widget.login-dialog');
    
  • 选项 3:使用Ext.widget,它是Ext.ClassManager.instantiateByAlias的简写:

    Ext.widget('login-dialog');
    
  • 选项 4:将xtype作为另一个组件的项目:

    items: [
      {
        xtype: 'login-dialog'
      }
    ]
    
  • 选项 5:使用new关键字:

    new Packt.view.login.Login();
    

在这本书中,我们将最常使用选项 1、3 和 4。选项 1、2、3 和 5 返回实例化组件的引用。

选项 5 不是一个好的实践。尽管选项 4 和 5 是 Ext JS 3 之前实例化类的唯一方式,但其他选项是在 Ext JS 4 中引入的,并且选项 5 已经弃用。

小贴士

尽管选项 5 在 Ext JS 4 及其后续版本中已经弃用,但我们仍然可以在 Ext JS 文档和官方 Ext JS 示例中找到一些使用new关键字的代码。但不要因此感到困惑。选项 5 应该总是避免使用!

然后我们将autoShow配置为true#4)。考虑以下代码行:

Ext.create('Packt.view.login.Login');

当我们执行前面的代码时,将创建Login类的实例(如果我们需要的话,我们可以将这个引用存储在变量中以供以后操作)。由于Login类是Window类的子类,它继承了所有行为,其中之一就是实例化时窗口不会自动显示。如果我们想在应用程序中显示Window类(或其任何子类),我们需要手动调用show()方法,如下所示:

Ext.create('Packt.view.login.Login').show();

上述代码的一个替代方案是将autoShow配置设置为true。这样,当实例化时,Window类(或我们情况下的login类)将自动显示。

我们还有窗口的高度#5)和宽度#6)。

我们将layout设置为fit#7)。总结一下,当父容器(在这种情况下,Login)只有一个子容器时,使用fit布局。由于我们的Login窗口将包含两个字段(用户名和密码),这两个字段需要放置在form子类中。在这种情况下,form子类将是Login类的子类。

我们将iconCls#8)设置为Login窗口;这样,窗口的标题栏将显示一个钥匙图标(我们将在本章后面设置图标)。我们也可以给窗口一个标题#9),在这种情况下,我们选择了Login

同样还有closeAction#10)和closable#11)配置。closeAction将告诉我们当我们关闭窗口时是否想要销毁它。在这种情况下,我们不想销毁它;我们只想隐藏它。而closable配置告诉我们是否想在窗口右上角显示X图标。由于这是一个Login窗口,我们不希望给用户这个选项(用户只能尝试提交用户名和密码来登录应用程序)。

注意

closehidedestroy方法之间有什么区别?close方法关闭面板,并且默认情况下,此方法将其从 DOM 中删除并销毁面板对象及其所有子组件。hide方法隐藏组件,将其设置为不可见(可以通过调用show方法再次使其可见)。而destroy方法清理对象及其资源,但将其从 DOM 中删除并释放对象,以便垃圾回收器可以清理它。

我们还有draggable#12)和resizable#13)配置。draggable配置控制组件是否可以在整个浏览器空间内进行拖动。当resizable配置设置为true(其默认值)时,用户可以滚动到组件的角落并调整其大小。

到目前为止,这是我们得到的输出——一个顶部左角带有空白图标、标题为登录(我们将在本章后面设置所有图标)的单个窗口:

创建登录界面

下一步是添加包含usernamepassword字段的form。我们将在Login类中添加以下代码(在#13行之后):

items: [
{
    xtype: 'form',          //#14
    bodyPadding: 15,        //#15
    defaults: {             //#16
        xtype: 'textfield', //#17
        anchor: '100%',     //#18
        labelWidth: 60      //#19
    },
    items: [
        {
            name: 'user',
            fieldLabel: 'User'
        },
        {
            inputType: 'password', //#20
            name: 'password',
            fieldLabel: 'Password'
        }
    ]
]

由于我们正在使用fit布局,我们只能在Login类内部声明一个子item。因此,我们将在Login类内部添加一个form#14)。请注意,在这里我们正在使用之前提到的选项 4。在 Ext JS 中声明项目时,这通常是实例化组件的方式(使用选项 4)。我们向form的主体添加了body padding#15),这将增加表单与窗口边框之间的空间,使其看起来更美观。

由于我们将在表单中添加两个字段,我们可能希望避免重复某些代码。这就是为什么我们将在formdefaults配置中声明一些字段配置(#16);这样,我们在defaults中声明的配置将应用于form的所有项目,我们只需要声明我们想要定制的配置。由于我们将声明两个字段,它们都将为textfield类型(#17)。

form组件默认使用的布局是anchor布局,因此我们不需要明确声明这一点。然而,我们希望两个字段都占据表单主体的所有可用水平空间。这就是为什么我们将anchor声明为100%#18)的原因。

虽然fit布局允许你渲染一个子组件,该组件将占据父容器内的所有可用空间,但Anchor布局使你能够将子容器相对于父容器尺寸进行定位。在这种情况下,我们希望文本字段占据表单中可用的 100%水平空间。如果我们希望文本字段只占据 70%的可用的水平空间,我们可以将anchor配置设置为70%

默认情况下,textfield类的标签的width属性是 100 像素。这对于UserPassword标签来说空间太多,所以我们将这个值减少到60 像素#19)。

最后,我们有user textfieldpassword textfield。配置name是我们提交表单到服务器时用来识别每个字段的内容。

只缺少一个细节:当用户在字段中输入密码时,系统不能显示其值——我们需要以某种方式将其隐藏。这就是为什么password字段的inputType'password'#20),因为我们想显示点而不是原始值——用户将看不到密码值。

注意

其他输入类型也可以与textfield一起使用。HTML5 的输入类型,如emailurltel也可以使用。然而,如果应用程序是从较旧的浏览器(或不支持输入类型的浏览器)中执行,Ext JS 会自动将其更改为默认值,即text。有关 HTML5 输入类型和支持每种类型的浏览器更多信息,请访问www.w3schools.com/html/html5_form_input_types.asp

现在我们已经对登录窗口进行了一些改进。这是到目前为止的输出:

创建登录屏幕

客户端验证

Ext JS 中的字段组件提供了一些客户端验证功能。这可以节省时间和带宽(系统只有在确认信息具有基本验证时才会进行服务器请求,我们也不需要等待服务器验证输入)。这也有助于指出用户在填写表格时出错的地方。当然,出于安全原因,在服务器端再次验证信息也是好的,但在此我们将专注于我们可以应用于登录窗口的表单验证。

让我们头脑风暴一下可以应用于用户名和密码字段的验证:

  • 用户名和密码必须是必填项——没有用户名和密码,你如何验证用户?

  • 用户只能在两个字段中输入字母数字字符(A-Z,a-z 和 0-9)

  • 用户只能在username字段中输入 3 到 25 个字符

  • 用户只能在password字段中输入 3 到 15 个字符

因此,让我们将以下代码添加到两个字段共有的部分中:

allowBlank: false, // #21
vtype: 'alphanum', // #22
minLength: 3,      // #23
msgTarget: 'under' // #24

我们将把前面的配置添加到formdefaults配置中,因为它们都适用于我们拥有的两个字段。首先,两者都需要是必填项(#21),其次,我们只能允许用户输入字母数字字符(#22),用户需要输入的最小字符数是三个(#23)。然后,最后一个常见的配置是我们希望在字段下方显示任何验证错误消息(#24)。

而为每个字段定制的唯一验证是,我们可以在用户字段中输入最多 25 个字符:

name: 'user', 
fieldLabel: 'User',
maxLength: 25

密码字段中最多只能输入 15 个字符:

inputType: 'password', 
name: 'password',
fieldLabel: 'Password',
maxLength: 15

在我们应用客户端验证后,如果用户在填写登录窗口时出错,我们将得到以下输出:

客户端验证

如果您不喜欢在字段下方显示的错误消息,我们可以更改错误消息出现的位置。我们只需更改msgTarget的值。可用的选项有:titleundersidenone。我们还可以将错误消息显示为tooltipqtip)或在特定目标中显示它们(特定组件的innerHTML)。

对于side选项,例如,红色感叹号将显示在字段的旁边,当用户将其鼠标悬停在其上时,将显示包含错误消息的tooltip。一旦输入有效(用户在用户字段中输入更多字符或从密码字段中删除一些字符),错误消息将自动消失)。

创建自定义 VTypes

许多系统都有密码的特殊格式。比如说,我们需要密码至少包含一个数字(0-9)、一个小写字母、一个大写字母、一个特殊字符(@、#、$、%,等等)以及长度在 6 到 20 个字符之间。

我们可以创建一个正则表达式来验证密码是否被输入到应用程序中。为此,我们可以创建一个自定义的VType来进行验证。要创建自定义VType很简单。在我们的例子中,我们可以创建一个名为customPass的自定义VType,如下所示:

Ext.apply(Ext.form.field.VTypes, {
    customPass: function(val, field) {
        return /^((?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%]).{6,20})/.test(val);
    },
    customPassText: 'Not a valid password.  Length must be at least 6 characters and maximum of 20\. Password must contain one digit, one letter lowercase, one letter uppercase, one special symbol @#$% and between 6 and 20 characters.'
});

我们自定义的VType名为customPass,我们需要声明一个函数来验证我们的正则表达式。customPassText是当用户输入错误的密码格式时将显示给用户的消息。

注意

要了解更多关于正则表达式的信息,请访问www.regular-expressions.info/

上述代码可以添加到代码的任何位置,例如在 Controller 的init函数中、在app.jslaunch函数中,或者甚至在一个单独的 JavaScript 文件中(推荐),在那里您可以放置所有自定义的Vtypes

注意

VType是一个单例类,它包含了一组常用的字段验证函数,并提供了一种创建可重用自定义字段验证的机制。有关此类和 Ext JS 支持的默认验证的更多信息,请访问docs.sencha.com/extjs/5.1/5.1.0-apidocs/#!/api/Ext.form.field.VTypes

app 目录下创建一个名为 CustomVTypes.js 的新文件。将前面的代码添加到这个文件中。现在,我们需要这个文件与我们的应用程序一起加载。但请抑制手动在 index.html 文件中包含这个 JavaScript 文件的冲动。我们将遵循最佳实践!

masteringextjs 文件夹中的 app.json 文件大约第 110 行找到以下代码:

"js": [
    {
        "path": "app.js",
        "bundle": true
    }
],

要使我们的 CustomVTypes.js 文件自动与我们的应用程序一起加载,我们只需添加以下突出显示的代码:

"js": [
    {
        "path": "app.js",
        "bundle": true
    },
    {
 "path": "app/CustomVTypes.js",
 "includeInBundle": true
 }
], 

includeInBundle 配置告诉 Sencha Cmd,这个文件需要添加到最终生成的 .js 文件中。

只有一个文件可以配置 bundle: true。这意味着它是应用程序的主文件。

小贴士

总是记得在终端窗口中运行 sencha app watch 命令,以便 Sencha Cmd 每次我们更改代码时都能进行新的构建。在这种情况下,CustomVTypes.js 将在没有进一步更改 index.html 文件的情况下被加载。真的很酷!

现在,让我们将自定义的 VType 应用到我们的代码中。将以下代码添加到密码字段:

vtype: 'customPass',
msgTarget: 'side'

此外,更改密码字段的提示目标。由于错误信息相当长,使用 under 作为消息目标看起来不会很好。这将是我们应用自定义 vType 后的结果:

创建自定义 VTypes

添加带有按钮的工具栏

到目前为止,我们创建了 登录 窗口,其中包含一个带有两个字段的表单,并且它已经被验证。唯一缺少的是添加两个按钮,取消提交

我们将添加按钮作为 toolbar 的项目,并将 toolbar 添加到 form 作为停靠项。dockedItems 可以停靠到面板的 顶部右侧左侧底部(表单和窗口组件都是面板的子类)。在这种情况下,我们将 dock toolbar 到表单的底部。在表单的项目配置之后添加以下代码:

dockedItems: [
    {
        xtype: 'toolbar',
        dock: 'bottom',
        items: [
                xtype: 'tbfill' //#25
            },
            {
                xtype: 'button', //#26
                iconCls: 'fa fa-times fa-lg',
                text: 'Cancel'
            },
            { 
                xtype: 'button', //#27
                formBind: true,  //#28
                iconCls: 'fa fa-sign-in fa-lg',
                text: 'Submit'
            }
        ]
    }
]

如果我们回顾一下本章开头首次展示的 登录 屏幕截图,我们会注意到有一个用于翻译/多语言功能的组件。在这个组件之后,有一个空格,然后是 取消提交 按钮。由于我们还没有多语言组件,我们只能实现这两个按钮,但它们需要放在表单的右端,并且我们需要留出那个空格。这就是为什么我们首先需要添加一个 tbfill 组件(#25),它将指导工具栏布局开始使用右对齐的按钮容器。

然后,我们将添加 取消 按钮(#26)和 提交 按钮(#27)。我们将为这两个按钮添加一个图标(iconCls),我们将在本章后面添加到 CSS 文件中。

我们已经有了客户端验证,但即使有了验证,用户仍然可以点击提交按钮,我们希望避免这种行为。这就是为什么我们将提交按钮绑定到表单(#28);这样,按钮只有在表单没有客户端验证错误的情况下才会启用。

在下面的屏幕截图中,我们可以看到添加工具栏后的当前登录表单输出,以及验证提交按钮的行为:

添加带有按钮的工具栏

小贴士

当我们想在表单中添加带有按钮的工具栏时,我们可以使用buttons配置来添加。更多信息,请访问goo.gl/X38h8Q

运行代码

为了执行我们到目前为止创建的代码,我们需要在Application.js文件中做一些更改。

首先,我们需要声明我们正在使用的views(在这种情况下只有一个),如下所示:

views: [
    'login.Login'
],

最后一个更改是在launch函数中。在前一章中,我们留下了一条console.log消息,在那里我们需要实例化我们的初始视图;现在我们只需要将console.log消息替换为Login实例(#1):

me.splashscreen.next().fadeOut({
    duration: 1000,
    remove:true,
    listeners: {
       afteranimate: function(el, startTime, eOpts ){
 Ext.widget('login-dialog'); //#1
        }
    }
});

现在Application.js没有问题,我们可以执行到目前为止所实现的内容!

关于 Ext JS 动态类加载的简要概述

动态类加载是在 Ext JS 4 中引入的。它提供了一种集成的依赖管理能力,这在开发(本地)环境中非常有用(在最终的生产构建中也扮演着重要的角色)。这也是为什么 Ext JS 中实例化类(使用关键字new)的选项 5 被弃用,并且不是最佳实践的原因之一。

动态加载意味着什么?这意味着在我们加载应用程序之前,我们不需要加载所有 Ext JS SDK 类。例如,对于登录窗口,我们正在使用 Ext JS SDK 中的WindowFormTextField类。为了执行我们的应用程序,我们不需要网格、树和图表的源代码。你同意吗?

仍然以登录窗口为例,当我们的应用程序加载时,Ext JS 将读取 'login.Login' 视图需要被加载。由于所有应用程序源代码都在 app 文件夹中,而视图在 app/view 文件夹中,Ext JS 加载器将期望找到 app/view/login/Login.js 文件,并且在这个文件中它期望找到 'Packt.view.login.Login' 类定义(这就是为什么遵循我们之前介绍的命名约定非常重要)。然后 Ext JS 加载器将看到这个类继承自 Ext.window.Window 类,如果这个类尚未加载,它将找出所有依赖项(从 extendrequires 声明中——我们将在稍后讨论 requires),并将它们加载,直到我们加载了执行应用程序所需的全部源代码(并且它将递归地这样做,直到所有代码都加载完毕)。

例如,当你尝试执行应用程序时,打开 Chrome 开发者工具(Ctrl + Shift + ICommand + Shift + I)或 Firefox 的 Firebug(启用所有面板)并打开 网络 选项卡。我们将能够看到为我们的应用程序加载的所有文件,如下所示:

关于 Ext JS 动态类加载的快速概述

我们知道 5MB 对于仅有的 登录 屏幕来说很可怕,但当我们在这本书的生产构建中解决此问题时,我们将解决这个问题。现在我们不需要担心它。

当我们进行生产构建时会发生什么,Ext JS 将知道需要包含在最终 JavaScript 文件中的 SDK 中的哪些类,将所有内容合并成一个文件,并且还会对其进行混淆。如果你尝试打开前面截图中的任何文件,你将能够阅读源代码(并且它将像开发源代码一样美观和缩进)。

添加 Font Awesome 支持(符号图标)

使用应用程序上的图标可以改善其外观和感觉,使应用程序看起来更漂亮,用户通常也会喜欢它。然而,除非我们(或购买)不同尺寸的图标,否则图标大小为 16 x 16 像素。随着 CSS3 的引入,其中一项新特性被称为 CSS3 网络字体 (www.w3schools.com/css/css3_fonts.asp),它允许我们使用用户计算机上未安装的字体。

此功能允许开发者创建一种新的图标类型,称为 符号图标,实际上并不是图标,而是每个字符看起来像图标的字体(类似于 Webding 字体,en.wikipedia.org/wiki/Webdings)。

使用图标符号很棒,因为我们可以更改图标的大小和颜色以匹配应用程序的主题。只要可能,我们将在我们的应用程序中使用图标符号。有一个开源且免费的字体,被现代应用程序(HTML5 应用程序)广泛使用,称为 Font Awesome,我们也将在这个项目中使用它。

因此,第一步是从 fortawesome.github.io/Font-Awesome/ 下载 Font Awesome 文件。点击 下载 按钮。将要下载的文件是一个 .zip 文件。解压它。将 fonts 文件夹复制并粘贴到 masteringextjs 应用程序的 resources 文件夹内。将 scss 文件夹复制并粘贴到 sass/etc 文件夹内。将 scss 文件夹重命名为 fontAwesome。这就是修改后 sass/etcresource 文件夹的样式:

添加 Font Awesome 支持(图标符号)

我们几乎完成了!打开 sass/etc/fontAwesome/_variables.scss 文件,并将变量 $fa-font-path 修改为以下值:

$fa-font-path: "../resources/fonts" !default;

这是为了告诉 Sass 我们放置字体文件的位置。

现在我们所需要做的就是打开 sass/etc/all.scss 文件,并在文件的第一行添加以下代码:

@import "fontAwesome/font-awesome";

如果你正在终端应用程序中运行 sencha app watch,你应该注意应用程序已被重新构建,我们现在可以查看应用程序中的图标。以下是如何显示 登录 屏幕的示例:

添加 Font Awesome 支持(图标符号)

下一步是为 取消提交 按钮添加一些操作。

注意

要了解更多关于 Sass 变量和导入功能的信息,请访问 sass-lang.com/guide

创建登录控制器

到目前为止,我们已经为 登录 屏幕创建了视图。由于我们遵循 MVC 架构,我们不会在 View 类中实现用户交互。如果我们点击 Login 类中的按钮,将不会发生任何操作,因为我们还没有实现这个逻辑。我们现在将在 Controller 类中实现这个逻辑。

在 Ext JS 5 中,我们有两种选择来完成这个任务:使用默认的 MVC 架构或使用 MVVM 架构模式(或 混合 模式)。

介绍 MVVM 架构

在上一章中,我们介绍了 Ext JS 中的 MVC 架构。让我们快速回顾一下 MVC 的工作原理:

介绍 MVVM 架构

Model代表应用程序正在使用的信息。View是用户将在屏幕上看到的内容——组件。在用户与应用程序的每次交互中,组件都会触发事件。Controller是我们将处理事件并执行所需逻辑的地方;控制器将管理信息(Model)并管理View(以及ViewModel之间的交互)。

在 Ext JS 5 中,Sencha 引入了这种新的模式,称为模型-视图-视图模型MVVM),如下面的图所示:

介绍 MVVM 架构

在 MVVM 中发生的情况是,如果它们被绑定,使用这种模式控制ViewModel会容易得多。例如,考虑我们有一个数据网格,其中列出了一些联系人。当我们选择一个联系人并点击编辑按钮时,我们希望应用程序打开一个弹出窗口,该窗口的标题将是联系人的名字,并且弹出窗口还将有一个表单,用于显示供编辑的联系人详细信息。如果我们使用默认的 MVC 模式,我们需要控制View(数据网格、弹出窗口和表单)与Model(联系人信息)之间的交互方式。MVVM(基于 MVC)引入了一个新的抽象实体,即ViewModelViewModelView和相关的Model之间进行调解。

然而,随着这种新模式和新的ViewModel抽象,Sencha 还引入了控制器的抽象,称为ViewControllerViewController非常类似于传统的 MVC 模式控制器,如下所示:

介绍 MVVM 架构

然而,正如我们在第二章,入门中学到的,MVC 模式的控制器是在应用程序的作用域中创建的,并且它们是唯一的实例(这意味着应用程序中每个控制器的单个实例)。只要应用程序在运行,控制器也就会存在。

ViewModelViewController是组件的一部分(我们曾在第一章,Sencha Ext JS 概述中学习了组件)。只要View存在,它们也就会存在。当View被销毁时,它们也会被销毁。这意味着我们可以节省一些内存(如果我们一次没有很多相同的View实例)。

如果你现在不完全理解这些概念,请不要担心。我们将通过一些示例学习如何使用它们以及它们是如何工作的,并且在这本书中,我们将使用这些不同的架构选项,以便我们可以了解每个选项是如何工作的,也许你可以选择你最喜欢的一个,或者最适合你项目的一个。

为登录视图创建 ViewController

让我们稍微停下来思考一下。登录是在应用的生命周期中只执行一次的操作。在应用中,我们可以做三件事:登录以开始使用它,使用其功能,或者注销(因为我们点击了注销按钮或会话过期)。一旦登录,我们就进入了,这就结束了。

在前面的主题中,我们了解到 ViewModelViewController 会在 View 被销毁时被销毁。因此,我们不需要在应用的生命周期中保持登录控制器活跃,我们可以有一个只在登录视图活跃期间存在的控制器。因此,对于 Login 屏幕,我们将使用 ViewController

第一步是创建 JavaScript 文件。在 app/view/login 目录下,我们将创建 LoginController.js 文件。在这个文件中,我们将实现以下代码,这将是我们要实现的 ViewController 类的基础:

Ext.define('Packt.view.login.LoginController', { // #1
    extend: 'Ext.app.ViewController',            // #2
    alias: 'controller.login',                   // #3 

    onTextFieldSpecialKey: function(field, e, options){ }, // #4

    onTextFieldKeyPress: function(field, e, options){ }, // #5

    onButtonClickCancel: function(button, e, options){ }, // #6

    onButtonClickSubmit: function(button, e, options){ }, // #7

    doLogin: function() { }, // #8

    onLoginFailure: function(form, action) { }, // #9

    onLoginSuccess: function(form, action) { } // #10
});

如同往常,在类的第一行,我们有它的名字 (#1)。遵循我们在 view/login/Login.js 中使用的相同公式,我们将有 Packt (应用命名空间) + view (包名) + login (子包名) + LoginController (文件名),结果为 Packt.view.login.LoginController

ViewController 类需要扩展自 Ext.app.ViewController (#2),这样我们就可以始终为我们的 ViewController 使用这个父类。

我们还需要为这个 ViewController 提供一个 alias (#3)。ViewController 的别名以 'controller' 开头,后跟我们要分配的别名(记住别名总是小写)。

对于 #4#10,我们有直到本章结束我们将要实现的一些方法的签名。我们将在稍后逐一介绍它们。

将 ViewController 绑定到 View

现在我们已经准备好了 ViewController 的基础,我们需要将 ViewController 绑定到其视图,即 Login 视图。回到 Packt.view.login.Login 类,我们将向该类添加以下配置:

controller: 'login',

上述配置将 ViewController 类绑定到 Login 类的生命周期。注意我们正在使用在 #3 中定义的别名。

如果你尝试执行代码,它将抛出错误。这是因为 Ext JS 不知道哪个 ViewController 类有 login 别名(因为这个别名不是框架的本地属性;我们正在创建它)。为了使其工作,我们还需要在 login 类中添加以下代码:

requires: [
    'Packt.view.login.LoginController'
],

这将告诉 Ext JS 加载器在加载 Login 类时也需要加载它。Ext JS 将加载这个类及其所有依赖项。当 Ext JS 解析 controller: 'login' 代码时,它将注册 login 别名用于控制器,并且一切都会正常。

监听按钮点击事件

我们接下来的步骤是开始监听登录窗口的事件。首先,我们将监听提交取消按钮。

由于我们使用的是ViewController类而不是 Controller(MVC),我们需要在Login类中添加监听器。首先,让我们为取消按钮做这件事,如下所示:

xtype: 'button',
iconCls: 'fa fa-times fa-lg',
text: 'Cancel',
listeners: {    click: 'onButtonClickCancel'}

这段代码的意思是,当用户点击取消按钮时,Login ViewController类中的onButtonClickCancel方法将被执行。所以让我们来实现这个方法!回到LoginController类,我们已经知道这是我们即将实现的方法:

onButtonClickCancel: function(button, e, options){}

但我们如何知道方法可以接收哪些参数呢?我们可以在文档中找到这个答案。如果我们查看文档中的点击事件(Button类),我们会发现以下内容:

监听按钮点击事件

这正是我们声明的。对于所有其他事件监听器,我们将查看文档,看看事件接受哪些参数,然后在我们代码中将它们作为参数列出。这也是一个非常好的实践。即使我们只对第一个(或者甚至没有)参数感兴趣,我们也应该列出文档中的所有参数。这样,我们总是知道我们拥有所有参数的完整集合,这在我们对应用程序进行维护时非常有用。

小贴士

确保在开发 Ext JS 应用程序时,将文档变成你的最佳朋友。Ext JS 文档非常好,且用户友好。

注意,我们还想监听提交按钮的点击。onButtonClickSubmit方法与onButtonClickCancel方法具有相同的签名。让我们继续添加监听器到提交按钮,如下所示:

xtype: 'button',
formBind: true,
iconCls: 'fa fa-sign-in fa-lg',
text: 'Submit',
listeners: {    click: 'onButtonClickSubmit'}

让我们快速测试一下,看看到目前为止一切是否按预期工作:

onButtonClickCancel: function(button, e, options){
    console.log('login cancel'); // #1
},

onButtonClickSubmit: function(button, e, options){
    console.log('login submit');  // #2          
},

现在,我们只会在控制台输出一条消息,以确保我们的代码正在正常工作。所以,如果用户点击提交按钮,我们将输出'login submit'#2),如果用户点击取消按钮,我们将输出'login cancel'#1)。

让我们开始尝试。点击取消按钮,然后点击提交按钮。这应该是输出结果:

监听按钮点击事件

取消按钮监听器实现

让我们移除console.log消息,并添加我们实际上想要方法执行的实际代码。首先,让我们专注于onButtonClickCancel方法。当我们执行这个方法时,我们希望它重置登录表单。

所以这是我们想要编程的逻辑序列:

  • 获取表单引用

  • 调用reset方法重置表单

如果我们查看onButtonClickCancel方法中可用的参数,我们有buttoneoptions,但没有一个提供了表单引用。那么我们该怎么办呢?

ViewController 类有一种有趣的方式来获取 Login 类或其子类的引用,它使用 ViewController 类中的 lookupReference(reference) 方法。为了能够使用这个方法,我们只需要在 Login View 类中为 form 添加一个引用:

xtype: 'form',
reference: 'form',

使用这个引用,我们将能够直接调用 this.lookupReference('form') 方法来检索表单引用。有了表单引用,我们只需要调用 form 类中的 reset() 方法。onButtonClickCancel 方法的完整代码如下:

onButtonClickCancel: function(button, e, options){
    this.lookupReference('form').reset();
},

提交按钮监听器实现

现在我们需要实现 onButtonClickSubmit 方法。在这个方法内部,我们想要编写逻辑来将 用户密码 值发送到服务器,以便进行用户认证。

我们可以在该方法内部实现两种编程逻辑:第一个是使用 Form Basic 类提供的 submit 方法,第二个是使用 Ajax 调用来提交值到服务器。无论哪种方式,我们都会达到我们的目标。对于这个例子,我们将使用默认的表单提交调用。

在这个方法中,我们需要执行以下步骤:

  • 获取 Login 表单引用

  • 获取 Login 窗口引用(以便在用户认证后关闭它)

  • 将登录信息发送到服务器

  • 处理服务器响应,如下所示:

    • 如果用户已认证,显示应用程序

    • 如果不是,显示错误信息

我们已经知道如何获取表单引用。这是 onButtonClickSubmit 的样子:

onButtonClickSubmit: function(button, e, options){
    var me = this;
    if (me.lookupReference('form').isValid()){ // #1
        me.doLogin();              // #2
    }
},

因此,首先,在做什么之前,我们将确保用户已经输入了所有必要的信息(用户名和有效的密码 #1)。如果一切正常,然后我们调用一个辅助方法来处理认证(#2),如下所示:

doLogin: function() {
    var me = this,
        form = me.lookupReference('form');

    form.submit({
        clientValidation: true,        // #3
        url: 'php/security/login.php', // #4
        scope: me,         // #5
        success: 'onLoginSuccess',     // #6
        failure: 'onLoginFailure'      // #7
    });
},

首先,只是为了确保我们试图提交的数据是有效的(我们也将从另一个方法中调用这个 doLogin 方法,所以确保我们发送给服务器的数据是有效的永远都不够!),我们将 clientValidation 配置设置为 true 以再次验证信息(#3)。然后我们有将要调用的 url#4)。success#6)和 failure#7)回调被声明为单独的函数,这些函数属于 ViewController 类,这就是为什么作用域是 ViewController 类(#5)。

小贴士

我们也可以在提交调用中实现成功和失败方法(如文档中的示例所示 docs.sencha.com/extjs/5.0.0/apidocs/#!/api/Ext.form.Basic-method-submit)。但我们不知道我们需要多少代码来处理认证。使用作用域回调更好,因为我们的代码保持组织性,有更好的可读性。

如果我们尝试运行此代码,应用程序将向服务器发送请求,但由于我们还没有实现login.php页面,我们将收到一个错误响应。这没关系,因为我们现在对其他细节更感兴趣。

启用 Firebug 或 Chrome 开发者工具,打开网络标签页并按XHR请求过滤。确保输入一个用户名密码(任何有效值,这样我们就可以点击提交按钮)。这将产生以下输出:

提交按钮监听器实现

注意,用户名和密码将以表单数据的形式发送。这些信息对于在服务器端(在我们的例子中是 PHP 代码)处理信息非常有用。

小贴士

每当你有问题或者不知道如何处理 Ext JS 发送到服务器的信息时,打开浏览器中的调试工具并检查调用。这非常有帮助,也能帮助你了解 Ext JS 与服务器通信时的工作方式。

创建用户和组表

在我们开始编写login.php页面之前,我们需要向 Sakila 数据库中添加两个表。这两个表将代表用户以及用户可以属于的组。在我们的项目中,一个用户只能属于一个组,如下面的图所示:

创建用户和组表

  1. 首先,我们将创建Group表,如下所示:

    CREATE  TABLE IF NOT EXISTS `sakila`.`Groups` (
      `id` INT NOT NULL AUTO_INCREMENT ,
      `name` VARCHAR(45) NOT NULL ,
      PRIMARY KEY (`id`) )
    ENGINE = InnoDB;
    
  2. 然后,我们将创建包含索引和指向Group表的外键User表:

    CREATE  TABLE IF NOT EXISTS `sakila`.`User` (
      `id` INT NOT NULL AUTO_INCREMENT ,
      `name` VARCHAR(100) NOT NULL ,
      `userName` VARCHAR(20) NOT NULL ,
      `password` VARCHAR(100) NOT NULL ,
      `email` VARCHAR(100) NOT NULL ,
      `picture` VARCHAR(100) NULL ,
      `Group_id` INT NOT NULL ,
      PRIMARY KEY (`id`, `Group_id`) ,
      UNIQUE INDEX `userName_UNIQUE` (`userName` ASC) ,
      INDEX `fk_User_Group1_idx` (`Group_id` ASC) ,
      CONSTRAINT `fk_User_Group1`
        FOREIGN KEY (`Group_id` )
        REFERENCES `sakila`.`Groups` (`id` )
        ON DELETE NO ACTION
        ON UPDATE NO ACTION)
    ENGINE = InnoDB;
    
  3. 下一步是将一些数据插入到这些表中:

    INSERT INTO `sakila`.`Groups` (`name`) VALUES ('admin');
    INSERT INTO `sakila`.`User` (`name`, `userName`, `password`, `email`, `Group_id`) 
    VALUES ('Loiane Groner', 'loiane', '$2a$10$2a4e8803c91cc5edca222evoNPfhdRyGEG9RZcg7.qGqTjuCgXKda', 'me@loiane.com', '1');
    

由于密码将在数据库中以散列形式保存,值$2a$10$2a4e8803c91cc5edca222evoNPfhdRyGEG9RZcg7.qGqTjuCgXKda对应于值Packt123@。我们将在用户管理模块中散列我们的密码以提高安全性。

现在我们已经准备好开始开发login.php页面。

处理服务器上的登录页面

由于我们有一部分 Ext JS 代码用于将登录信息发送到服务器,我们可以实现服务器端代码。正如本书第一章中提到的,我们将使用 PHP 来实现服务器端代码。但是如果你不知道 PHP,不要担心,因为代码不会很复杂,我们也将使用纯 PHP。目标是关注我们需要在服务器端使用的编程逻辑;这样我们就可以将相同的编程逻辑应用到任何你喜欢的其他服务器端语言(Java、.NET、Ruby、Python 等)。

连接到数据库

第一步是创建一个负责连接数据库的文件。我们将在我们开发的几乎每个 PHP 页面中重用这个文件。

在项目的根文件夹下创建一个名为php的新文件夹,然后在php下创建一个名为db的新文件夹。然后,创建一个名为db.php的新文件:

<?php 
$server = "127.0.0.1";
$user = "root";
$pass = "root";
$dbName = "sakila";

$mysqli = new mysqli($server, $user, $pass, $dbName);

/* check connection */
if ($mysqli->connect_errno) {
    printf("Connect failed: %s\n", mysqli_connect_error());
    exit();
}
?>

连接相当直接。我们只需通知 服务器(它将是 localhost),数据库的 用户名密码,以及我们想要连接的数据库 名称。最后,我们可以检查连接是否成功完成或是否发生任何错误。

注意

更多关于 MySQLi 的信息,请访问 php.net/manual/en/book.mysqli.php

Login.php

最后,我们可以在 php/security 文件夹下创建 login.php 文件。所以让我们开始实现它,如下所示:

require("../db/db.php"); // #1
require("PassHash.php"); // #2

session_start();         // #3

$userName = $_POST['user']; // #4
$pass = $_POST['password']; // #5

$userName = stripslashes($userName); // #6
$pass = stripslashes($pass);         // #7

$userName = $mysqli->real_escape_string($userName); // #8
$sql = "SELECT * FROM USER WHERE userName='$userName'"; // #9

首先,我们需要 require db.php 文件以连接到数据库(#1)。我们还将 require PassHash.php 文件(#2)。此文件包含 check_password 方法,该方法将比较用户输入的密码与数据库中存储的密码(已散列)。

然后,我们开始一个会话(#3)——我们稍后将在会话中存储用户名。

下一步是从 Ext JS (#4#5) 的表单提交方法中检索 用户密码 值。

stripslashes 函数从给定的字符串中移除反斜杠(#6#7)。例如,如果用户值是 "Loiane\'s",则 stripslashes 的返回值将是 "Loiane's"

提示

这两个步骤有助于确保应用程序的安全性;然而,它们并不足够。在服务器上对用户输入进行 清理 非常重要,这样我们就不存储或尝试使用恶意输入执行 SQL 语句。为了本书的目的,我们将不会应用这项技术以保持服务器端代码简单,因此即使你不知道 PHP,你也能阅读并理解其背后的逻辑,并在你选择的任何服务器端语言中实现类似的功能。然而,请注意,在实际应用中,应用这一步骤非常重要,尤其是如果你将应用程序发布给公众(而不仅仅是内部使用)。

有一个名为 Open Web Application Security ProjectOWASP)的项目,它是免费的开源项目,提供了一套库和 API,用于在应用程序中应用安全技术。有针对 .NET、Java 和 PHP 的子项目,有关如何避免 XSS 攻击和 SQL 注入的教程,以及如何防止其他安全漏洞。更多信息,请访问 www.owasp.org

然后,我们使用 real_escape_string 函数(#8)为 SQL 语句准备 $username 变量,该函数用于在字符串中转义特殊字符,以便在 SQL 语句中使用。

接下来,我们准备将要执行的 SQL 查询(#9)。这是一个简单的 SELECT 语句,将返回与给定 用户名 匹配的结果。

让我们继续下一部分的代码:

if ($resultDb = $mysqli->query($sql)) { //#10

  $count = $resultDb->num_rows; //#11

  if($count==1){ //#12

        $record = $resultDb->fetch_assoc(); //#13

         //#14  
        if (PassHash::check_password($record['password'],$pass)){
            $_SESSION['authenticated'] = "yes"; //#15
            $_SESSION['username'] = $userName; //#16

            $result['success'] = true; //#17
            $result['msg'] = 'User authenticated!'; //#18
        } else{
            $result['success'] = false; //#19
            $result['msg'] = 'Incorrect password.'; //#20
        }
  } else {
    $result['success'] = false; //#21
    $result['msg'] = 'Incorrect user or password.'; //#22
  }
  $resultDb->close(); //#23
}

接下来,我们需要执行 SQL 查询,并将结果集存储在 resultDb 变量中 (#10)。然后,我们将根据结果集中是否有行返回来存储数据 (#11)。

现在是代码中最重要的部分。我们将验证结果集是否返回了行。因为我们传递了 username,结果集中返回的行数必须正好是 1。所以,如果行数等于 1 (#12),我们需要查看数据库中存储的哈希密码是否与用户输入的密码匹配,但首先,我们需要从数据库中检索到的记录中获取这些信息 (#13)。

PassHash 类负责对密码进行哈希处理,使得将哈希密码保存到数据库中(而不是明文密码)更加安全,用于从数据库中解密哈希密码 ($record['password']),以及与用户在登录页面输入的密码进行比较 (#14)。

注意

目前,您可以从本书下载的源代码中获取 PassHash.php 的完整代码。在 第六章,用户管理中,我们将逐行分析。

如果用户输入的密码和从数据库中解密后的哈希密码匹配,这意味着用户可以被认证。我们将存储认证用户的 username (#16) 在 Session 中,并且也存储用户已 authenticated (#15) 的信息。

我们还需要准备将要返回给 Ext JS 的结果。我们将发送两块信息:第一块是关于用户是否 authenticated (#17)——在这种情况下 "true"——我们还可以发送一条消息 (#18)。

如果用户输入的密码和数据库中的密码不匹配,那么我们还需要向 Ext JS 返回一些信息。success 将会是 false (#19),并且我们将返回一条消息以便向用户显示 (#20)。

如果 username 在数据库中不存在(结果集中的行数与 1 不同),我们也将向 Ext JS 发送一条消息,说明用户提供的用户名或密码不正确 (#22)。因此,success 信息将是 false (#21)。

然后,我们需要关闭结果集 (#23)。

现在,login.php 代码的第三和最后一部分:

$mysqli->close(); // #23

echo json_encode($result); // #24

我们需要关闭数据库连接 (#23),并且我们将以 JSON 格式 encode 我们将要发送回 Ext JS 的 result (#24)。

现在,login.php 代码已经完成。我们不能忘记在前面代码之前添加 <?php

处理服务器的返回——是否已登录?

我们已经处理了服务器端代码。现在,我们需要回到 Ext JS 代码,并处理来自服务器的响应。

在 Ext JS 中,成功和失败有两个不同的概念。表单以一种方式处理它,而 Ajax 请求以另一种方式处理。这可能会有些令人困惑,因此我们将通过表单提交(如本例所示)和 Ajax 请求来实现对服务器的请求,这样我们可以学习如何使用这两种方式来实现适当的代码。

对于表单,服务器需要返回 success: true 信息,以便执行的回调是成功的。对于失败,服务器需要返回 success: false,这可以在发生任何通信错误时返回(页面未找到,服务器异常等)。对于 Ajax 请求,successtrue 还是 false 都没关系;它将执行成功回调;只有当发生任何通信错误时,它才会执行失败回调。

注意

记住服务器需要返回给 Ext JS 的内容类型是 application/json,并且是 JSON 格式。

让我们先处理成功回调。在成功的情况下,onLoginSuccess 方法将被执行。在这种情况下,我们希望关闭 登录 窗口并显示应用程序的主屏幕,如下所示:

onLoginSuccess: function(form, action) {
    this.getView().close();             //#1
    Ext.create('Packt.view.main.Main'); //#2
}

Window 类有一个名为 close 的方法,我们可以调用它来关闭窗口。问题是如何获取 login window 类的引用。ViewController 类直接绑定到它,我们可以通过调用 ViewController 类的 getView 方法(#1)来引用 Login 类本身。然后,我们可以通过实例化由 Sencha Cmd 在创建应用程序时创建的 Main 类(#2)来创建主屏幕。我们将重用这个类来创建我们的主屏幕。

提示

使用前面提到的方法,代码的安全性存在一个缺陷。一个聪明的用户,如果理解了 Ext JS 的工作原理,即使用户未认证,也可以使用类似于前面的代码来访问主页。更安全的方法是将用户重定向到包含应用程序的页面(直接调用 Main 类)。由于我们在这里使用的是示例,这是可以接受的。然而,在开发真实应用程序时,请记住这一点!

在发生失败的情况下,我们需要处理两种情况:第一种情况是如果用户未认证,因为用户不存在或密码不正确。第二种情况是如果发生任何通信故障(例如,错误 404)。我们的 onLoginFailure 方法将如下所示:

onLoginFailure: function(form, action) {

  var result = Ext.JSON.decode(action.response.responseText, true); //#3

  if (!result){ //#4
      result = {};
      result.success = false;
      result.msg = action.response.responseText;
  }

  switch (action.failureType) {
      case Ext.form.action.Action.CLIENT_INVALID:  //#5
          Ext.Msg.show({
            title:'Error!',
            msg: 'Form fields may not be submitted with invalid values',
            icon: Ext.Msg.ERROR,
            buttons: Ext.Msg.OK
        });
      break;
      case Ext.form.action.Action.CONNECT_FAILURE:  //#6
        Ext.Msg.show({
            title:'Error!',
            msg: 'Form fields may not be submitted with invalid values',
            icon: Ext.Msg.ERROR,
            buttons: Ext.Msg.OK
        });
         break;
      case Ext.form.action.Action.SERVER_INVALID:  //#7
          Ext.Msg.show({
            title:'Error!',
            msg: result.msg, //#8
            icon: Ext.Msg.ERROR,
            buttons: Ext.Msg.OK
        });
  }
},

在我们深入失败回调之前,请注意 onLoginFailureonLoginSuccess 都接收两个参数:formaction。它们从哪里来?

如果我们查看文档,特别是Form类(Ext.form.Panel)的submit方法,我们将看到这个submit方法正在调用Ext.form.Basic类中的submit方法(这个类实际上包含处理表单操作的所有方法)。如果我们查看Ext.form.Basic类中的submit方法(docs.sencha.com/extjs/5.0/5.0.1-apidocs/#!/api/Ext.form.Basic-method-submit),我们将看到与我们的代码类似的示例。如果我们阅读描述,它说这个submit方法是从同一类中doAction方法的快捷方式。

如果我们打开此方法的文档(docs.sencha.com/extjs/5.0/apidocs/#!/api/Ext.form.Basic-method-doAction),我们将能够看到我们用于表单提交调用的参数(urlsuccessfailure回调函数等),以及成功和失败回调函数接收到的参数——formaction——如下所示:

处理服务器返回 – 已登录或未登录?

action参数内部包含四个属性。对于我们失败的回调函数,我们感兴趣的是其中的两个:failureTyperesponse。首先让我们分析response。将以下代码(console.log(action);)添加到失败回调函数的第一行,并在登录屏幕中尝试提交错误的用户名或密码。在提交到服务器之前,打开 Chrome 开发者工具或 Firebug,查看将要记录的内容,如下所示:

处理服务器返回 – 已登录或未登录?

在响应中,请注意其中包含我们从服务器返回的 JSON 格式的responseText。因此,我们首先要做的是解码这个 JSON(#3)。解码之后,我们将能够访问result.successresult.msg。我们还需要注意一个细节:我们不知道服务器将返回什么。我们总是希望它是我们的successmsg信息;然而,我们无法确定。如果返回任何其他错误,它也将包含在action.response.responseText中,并且它不能是我们期望的 JSON 格式(也不能是 JSON)。如果发生这种情况,Ext.JSON.decode将失败,并抛出异常。我们可以静默异常(将true作为Ext.JSON.decode函数的第二个参数传递,result将具有null值),但我们仍然需要处理它。这正是我们在检查result变量是否为null#4)时所做的。如果是 null,我们将实例化result并分配一些值(msg将接收服务器发送的错误)。

之后,我们将使用 failureType 动作来查看发生了哪种类型的错误。由于 failureType 是代码,Ext JS 定义了一些对开发者更友好的常量(例如 Ext.form.action.Action.CLIENT_INVALID)。如果 failureType'client' (#5),那么我们将通过带有错误图标的弹出警告显示错误消息。如果服务器发生了连接错误,那么 (#6) 将通过显示错误弹出警告来处理它。如果返回了任何异常或成功为假,(#7) 将处理它。由于我们处理了服务器的返回值以显示自定义错误消息或任何其他消息,我们可以在弹出警告中简单地显示 result.msg (#8)。

再次尝试输入错误用户名或密码并查看会发生什么。将 login.php url 更改为 login.php(或更改为任何其他 url),或在 db.php 文件中输入错误的密码以连接到数据库来模拟错误,你将看到以下内容:

处理服务器的返回 - 是否已登录?

这样,我们可以处理所有类型的服务器响应;不仅是我们所期望的,还包括任何异常!

通过创建 Util 类重用代码

注意,在 (#5)、(#6) 和 (#7) 中,我们使用的是相同的错误弹出警告,因此代码是重复的。这种类型的错误弹出警告被用于应用程序的不同位置。由于我们将在应用程序的其他屏幕中处理更多的 Ajax 请求和表单提交,因此 (#3) 和 (#4) 中的代码也将重复。因此,我们可以创建一个 Util 类来封装此代码并提供重用它的方法。除了重用优势之外,它还很好地建立了一个应用程序可以遵循的模式,例如确定服务器需要返回给 Ext JS 的 JSON 格式。这将使应用程序更有组织性,当团队合作时也很好(通常每个开发者都有自己的他们喜欢遵循的模式,这样我们就可以为同一应用程序遵循相同的模式,并且看起来不像是由不同的开发者实现的)。

因此,让我们继续创建我们的第一个 Util 类。我们将将其命名为 Packt.util.Util。因此,我们将创建一个名为 Util.js 的新文件,我们还将创建一个位于 app 文件夹下的名为 util 的新文件夹,如下所示:

Ext.define('Packt.util.Util', {

    statics : { //#1

        decodeJSON : function (text) { //#2
            var result = Ext.JSON.decode(text, true);
            if (!result){
                result = {};
                result.success = false;
                result.msg = text;
            }

            return result;
        },

        showErrorMsg: function (text) { //#3
            Ext.Msg.show({
                title:'Error!',
                msg: text,
                icon: Ext.Msg.ERROR,
                buttons: Ext.Msg.OK
            });
        }
    }
});

所有方法都将位于 statics 声明中 (#1)。正如我们在 第一章 中所学到的,Sencha Ext JS 概述,我们可以简单地调用 Packt.util.Util.decodeJSON,例如,而不需要实例化 Packt.util.Util 类。decodeJSON 方法 (#2) 包含处理 JSON 解码的代码,而 showErrorMsg 方法 (#3) 包含显示带有传入文本参数的错误弹出警告的代码。

小贴士

静态方法不需要调用类的实例。这是面向对象编程的一个概念。

让我们使用Util类重写onLoginFailure方法,如下所示:

onLoginFailure: function(form, action) {

    var result = Packt.util.Util.decodeJSON(action.response.responseText);

    switch (action.failureType) {
        case Ext.form.action.Action.CLIENT_INVALID:
            Packt.util.Util.showErrorMsg('Form fields may not be submitted with invalid values');
            break;
        case Ext.form.action.Action.CONNECT_FAILURE:
       Packt.util.Util.showErrorMsg(action.response.responseText);
            break;
        case Ext.form.action.Action.SERVER_INVALID:
            Packt.util.Util.showErrorMsg(result.msg);
    }
}, 

现在我们只有 15 行代码,比之前的 36 行代码可读性更好!如果我们需要维护这段代码,我们可以在Util类中进行更改,这些更改将应用到使用该类的代码的每个地方!最佳实践让我们的代码变得非常酷!

最后一个细节:我们需要在ViewController类的requires声明中添加Packt.util.Util类:

requires: [
    'Packt.util.Util'
],

这是因为我们在本章前面讨论的动态加载。如果我们尝试在没有加载Util类的情况下执行前面的代码,我们可能会得到一个错误。

增强登录界面

我们的登录界面已经完成。然而,我们还可以对其应用一些增强,使其变得更好,并为用户提供更好的体验。

以下列表详细说明了我们将在我们的登录界面中应用的功能增强:

  • 在验证时应用加载遮罩

  • 当用户按下Enter键时提交表单

  • 显示大写锁定警告信息

在验证表单时应用加载遮罩

有时,当用户点击提交按钮时,在等待服务器发送响应的过程中可能会有一些延迟。一些用户可能会耐心等待,而另一些用户则不会。那些不太有耐心的用户将能够再次点击提交按钮,这意味着向服务器发送另一个请求。我们可以在等待响应时对登录窗口应用加载遮罩来避免这种行为。

首先,我们需要在form.submit调用之前(在doLogin方法内部)添加以下代码:

this.getView().mask('Authenticating... Please wait...');

这将对登录屏幕应用遮罩。

然后,在onLoginSuccessonLoginFailure函数的第一行中,我们需要添加以下代码行:

this.getView().unmask();

这将移除登录窗口的遮罩。

如果我们尝试执行代码,我们将得到以下输出:

在验证表单时应用加载遮罩

注意,登录界面不可达,用户无法在服务器发送响应并移除遮罩之前再次点击按钮。

Enter 键表单提交

对于某些表单,尤其是登录表单,当用户准备好时按下Enter键是非常自然的。对于 Ext JS 来说,这种行为不是自动的;因此,我们必须实现它。

textfield组件有一个处理特殊键的事件,例如Enter。这个事件被称为specialkey,这是我们将在登录控制器中监听的事件。因为我们想监听我们拥有的两个文本字段(用户密码)的此事件,我们可以在登录窗口的表单的默认值中添加以下代码:

listeners: {
    specialKey: 'onTextFieldSpecialKey'
}

接下来,我们还需要在ViewController类内部实现onTextFieldSpecialKey方法,如下所示:

onTextFieldSpecialKey: function(field, e, options){
    if (e.getKey() === e.ENTER) {
        this.doLogin();
    }
},

首先,我们将验证用户按下的键是否为Enter。如果是,我们将调用我们之前实现的doLogin方法。然后,将进行表单验证,如果表单有效,它将尝试登录。这将与点击提交按钮相同。

Caps Lock 警告信息

我们将对表单进行的最后一个增强是Caps Lock信息。有时Caps Lock键处于激活状态,当我们输入密码时,我们可以输入正确的密码,但系统会说它不正确,因为它是区分大小写的;提醒用户这一点是个好主意。

以下截图展示了Caps Lock警告实现的最终结果:

Caps Lock 警告信息

如前一个截图所示,我们将以工具提示的形式显示警告。所以我们需要做的第一件事是回到Application.js启动函数,并在第一行添加以下代码:

Ext.tip.QuickTipManager.init();

另一个选择是在Aplication.js中配置enableQuickTips: true。你可以使用任何一个,结果都会相同。

没有前面的代码,应用中的工具提示将无法工作。

Ext JS 有两种工具提示的概念。第一个是Tooltip类,它没有内置的方法可以自动根据目标元素填充工具提示文本;你必须为每个工具提示实例配置一个固定的 HTML 值,或者实现自定义逻辑(在事件监听器内部)。第二个是QuickTip类,它可以自动填充和配置工具提示,基于每个目标元素特定的 DOM 属性。工具提示默认启用。QuickTips 由QuickTipManager管理,需要手动启动。

我们将要监听的事件是keypress事件,我们只监听由password字段触发的事件。默认情况下,textfield组件不会触发此事件,因为它在性能上有点沉重。因为我们想监听这个事件,所以我们需要向password字段添加一个配置(enableKeyEvents)(在view/login/Login.js文件中):

id: 'password',
enableKeyEvents: true,
listeners: {
    keypress: 'onTextFieldKeyPress'
}

我们还需要给这个字段添加一个id值。稍后,我们将讨论避免在组件中使用id的重要性(因为它不是一种好做法),但在这个情况下,我们无能为力。这是因为当创建Tooltip类时,我们需要设置一个target(在这种情况下,是password字段),而这个target只接受组件的id,而不是itemId

在我们将代码添加到 Controller 之前,我们需要创建Tooltip类。我们将创建一个新的视图,名为Packt.view.login.CapsLockTooltip,因此我们需要在app/view/login文件夹下创建一个名为CapsLockTooltip.js的文件:

Ext.define('Packt.view.login.CapsLockTooltip', {
    extend: 'Ext.tip.QuickTip',

    xtype: 'capslocktooltip',

    target: 'password',
    anchor: 'top',
    anchorOffset: 0,
    width: 300,
    dismissDelay: 0,
    autoHide: false,
    title: '<div class="fa fa-exclamation-triangle"> Caps Lock is On</div>',
    html: '<div>Having Caps Lock on may cause you to enter ' +
        'your password incorrectly.</div><br/>' +
        '<div>You should press Caps Lock to turn it off ' +
        'before entering your password.</div>'
});

Packt.view.login.CapsLockTooltip中,我们声明了一些配置,这些配置将设置Tooltip类的行为。例如,我们有以下配置:

  • target:这具有password字段的id值。

  • anchor:这表示提示应该锚定到目标元素(password id字段)的特定侧面,箭头指向目标。

  • anchorOffset:这是一个数值(以像素为单位),用于偏移锚点箭头的默认位置。在这种情况下,箭头将在工具提示框开始后的 60 像素处显示。

  • width:这是表示工具提示框宽度的数值(以像素为单位)。

  • dismissDelay:这是在工具提示自动隐藏之前的延迟值(以毫秒为单位)。由于我们不希望工具提示自动隐藏,我们将值设置为0(零)以禁用它。

  • autoHide:将其设置为true以在鼠标退出目标元素后自动隐藏工具提示。由于我们不希望这样做,我们将其设置为false

  • title:这是用作提示标题的文本。

  • html:这是将在工具提示体中显示的 HTML 片段。

注意,我们在标题上给<div>标签添加了一个类。这将显示我们之前配置的 Font Awesome 的警告图标。

注意

要查看应用程序中可用的所有 Font Awesome 图标,请访问fortawesome.github.io/Font-Awesome/cheatsheet/

最后,我们需要在ViewController类中进行一些修改。首先,在requires声明中,我们将添加CapsLockTooltip类:

requires: [
 'Packt.view.login.CapsLockTooltip',
    'Packt.util.Util'
],

接下来,我们将实现onTextFieldKeyPress方法,如下所示:

onTextFieldKeyPress: function(field, e, options){

    var charCode = e.getCharCode(),  
        me = this;

    if((e.shiftKey && charCode >= 97 && charCode <= 122) || //#2
        (!e.shiftKey && charCode >= 65 && charCode <= 90)){

        if(me.capslockTooltip === undefined){                 //#3
          me.capslockTooltip = Ext.widget('capslocktooltip'); //#4
        }

        me.capslockTooltip.show(); //#5

    } else {

        if(me.capslockTooltip !== undefined){ //#6
            me.capslockTooltip.hide();        //#7
        }
    }
},

首先,我们需要获取用户按下的键的code#1)。然后,我们需要验证是否按下了Shift键,并且用户按下了小写字母键(a-z),或者如果没有按下Shift键,用户按下了大写字母键(A-Z)(#2)。如果这个验证的结果为真,这意味着Caps Lock是激活的。如果您想检查每个键的值,可以访问www.asciitable.com/

如果Caps Lock是激活的,我们将验证是否存在CapsLockTooltip类的引用(#3)。如果没有,我们将使用它的xtype创建一个引用,并将其存储在名为capslockTooltip的变量中。这个变量将作为ViewController类的一部分创建,因此如果这个方法再次执行,我们可以访问它。然后,我们通过执行显示的方法来显示它(#5)。

如果 大写锁定 未激活,我们需要验证是否存在对 CapsLockTooltip 类的引用 (#6)。如果是肯定的,我们将 隐藏 提示,因为 大写锁定 未激活。

大写锁定警告代码现在已完成。我们可以保存项目并测试它。

摘要

在本章中,我们逐步介绍了如何实现登录页面的细节。我们介绍了如何创建登录视图和 Login ViewController 类。我们在表单上应用客户端验证,以确保我们发送的数据对服务器是可接受的。我们介绍了如何使用 PHP 进行基本登录,以及如何处理服务器将发送回 Ext JS 的数据的重要概念。

我们了解了一些可以应用于 登录 屏幕的增强功能,例如当用户按下 Enter 键时提交表单,在密码字段中显示大写锁定警告,以及如何在表单发送数据并等待从服务器获取信息时应用加载遮罩。

我们还添加了对 Font Awesome 的支持,该支持将在我们的整个项目中使用。

在下一章中,我们将继续在 登录 屏幕上工作。我们将学习如何添加多语言功能,并实现注销和会话监控功能。

第四章:注销和多语言功能

在本章中,我们将实现系统的多语言功能。这个功能将允许系统根据用户选择的语言显示标签的翻译(同时使用一些 HTML5 功能)。

我们还将学习如何实现注销功能,以便用户可以结束会话,并且出于安全原因,我们将学习如何在用户不活动(一段时间内没有使用鼠标或键盘)的情况下为用户实现会话超时警告。

此外,在用户认证后,我们需要显示应用程序。在本章中,我们将学习如何实现应用程序的基础。

因此,在本章中,我们将涵盖:

  • 应用程序的基础

  • 注销功能

  • 活动监控和会话超时警告

  • 结构化应用程序以接收多语言功能

  • 创建更改语言组件

  • 在运行时处理更改语言组件

当我们介绍所有应用程序功能时,我们还将介绍一些 Ext JS 组件。

应用程序的基础 – view/main/Main.js

当我们在登录控制器中实现提交按钮监听器的success函数时,我们提到了Packt.view.main.Main类。我们将重用这个类(它是在我们创建项目时由 Sencha Cmd 自动创建的)作为我们应用程序的基础。在我们开始动手实践之前,让我们看看本章结束时应用程序的结果:

应用程序的基础 – view/main/Main.js

视口

无论我们是否完全使用 Ext JS 构建应用程序(因为我们确实有选择将单个组件渲染到<div>标签中的选项,类似于 jQuery 中执行的方式),我们都需要使用一个将成为应用程序基础的组件。这个组件是视口。视口是一个特殊的容器,代表可查看的应用程序区域(浏览器视口)。视口将自身渲染到文档主体,并自动调整自身大小以匹配浏览器视口的大小,并管理窗口大小调整。应用程序中可能只有一个视口被创建。

在我们创建视口之前,如果我们点击登录屏幕的提交按钮,我们会看到一个灰色屏幕,即使我们在LoginController类内部调用了Ext.create('Packt.view.main.Main');。这意味着Packt.view.main.Main正在被创建,但屏幕上没有显示任何内容。这是因为Main类没有被渲染为任何组件的子组件,也没有被渲染到 HTML 主体中。但我们将通过将其更改为视口来改变这种行为。

打开app/view/main/Main.js文件。在代码的第二行,你会找到以下代码片段:

extend: 'Ext.container.Container'

由 Sencha Cmd 创建的 Main 类扩展了 Container 组件。Container 组件是 Ext JS API 中最简单的容器组件。它支持向其中添加和移除项目,同时也是许多其他组件的父类,例如 Panel、Window 和 TabPanel。我们将把 Ext.container.Container 改为 Ext.container.Viewport,这样我们就可以将 Main 类作为我们应用程序的基类。保存代码,刷新浏览器,试一试。下次当你点击 提交 按钮时,登录后你应该能看到由 Sencha Cmd 创建的原始代码。

使用 Viewport 插件

在 Ext JS 应用程序中扩展 Ext.container.Viewport 类是经典和传统的方式。Ext JS 5 引入了一种使用 Viewport 的新方法,即使用 Viewport 插件 (Ext.plugin.Viewport)。

注意

要了解更多关于 Ext JS 插件的信息,请阅读www.sencha.com/blog/advanced-plugin-development-with-ext-js/

要使用这个插件,首先撤销前面主题中我们所做的更改(Main 类将继续扩展 Ext.container.Container),然后在 extend 代码之后添加以下代码:

Ext.define('Packt.view.main.Main', {
    extend: 'Ext.container.Container',

 plugins: 'viewport',

    xtype: 'app-main',

使用 plugins 配置与扩展 Viewport 类有相同的结果。这个插件将任何组件转换为一个 Viewport,使其填充浏览器中的所有可用空间。这个插件的优势在于我们仍然可以在其他上下文中重用这个类,例如在窗口内部。

我们知道,通过查阅文档,Viewport 插件的 ptype(插件类型)是 viewport

使用 Viewport 插件

小贴士

总是记住,在开发 Ext JS 应用程序时,文档需要成为你的最佳朋友!

使用 Border 布局组织主屏幕

如我们在第一章中学习到的,Sencha Ext JS 概述,Border 布局可以用来将父容器的子元素组织成五个区域:北、南、西、东和中心。

中心区域是唯一必须存在的区域。其他区域是可选的。查看下面的截图,我们可以看到我们将把主屏幕组织成四个区域:中心西

使用 Border 布局组织主屏幕

让我们看看 Main 类的 items 配置(你可以用以下代码替换由 Sencha Cmd 生成的代码):

items: [{
    region: 'center',   // #1
    xtype: 'mainpanel'
},{
    xtype: 'appheader', // #2
    region: 'north'
},{
    xtype: 'appfooter', // #3
    region: 'south'
},{
    xtype: 'container', // #4
    region: 'west',
    width: 200,
    split: true
}]

center区域,我们有mainpanel#1)。在第五章,高级动态菜单中,我们将创建一个动态菜单,将给用户打开他们有权访问的屏幕的选项。用户打开的每个屏幕都将作为mainpanel中的一个标签创建。我们将在下一分钟创建它。

north区域,我们有头部(#2),在south区域,我们有页脚(#3)。我们也将很快对它们进行处理。

west区域,我们有container#4),我们将在下一章中使用它来渲染动态菜单。现在,我们将为它预留空间。

重要的是要知道,对于center区域,我们不需要指定widthheight。在center区域渲染的容器将使用 Border 布局中剩余的任何空间。对于southnorth区域,你需要指定height。我们将创建HeaderFooter时这样做。southnorth区域将使用屏幕上所有可用的水平空间——受height限制——这就是为什么不需要width。对于westeast区域,需要指定width。因为我们只使用west区域,所以我们指定了200像素(#4)。

创建主 TabPanel 组件

我们需要创建在Main类的center区域使用的mainpanel组件。为此,我们将在app/view/main文件夹内创建一个名为Panel.js的新文件,并在其中编写以下代码:

Ext.define('Packt.view.main.Panel', { // #1
    extend: 'Ext.tab.Panel',          // #2
    xtype: 'mainpanel',             // #3

    activeTab: 0,                     // #4

    items: [
        {
            xtype: 'panel',                      // #5
            closable: false,                     // #6
            iconCls: 'fa fa-home fa-lg tabIcon', // #7
            title: 'Home'          // #8 
        }
    ]
});

如同往常,我们将从类的名称开始。类的命名约定是app 命名空间 + 文件夹(在app内部)+ 文件名(不带.js扩展名),这将导致Packt.view.main.Panel#1)。main.Panel类是扩展 TabPanel 组件的(#2)。

在第#3行,我们有main.Panel类的xtype。这是我们用来在Main类内部实例化这个类的xytpe类。在第#4行,我们有activeTab配置。当我们设置一个标签为活动状态时,TabPanel 组件将显示该标签的内容,并且也会将其突出显示。

如我们在第一章中学习的,Sencha Ext JS 概述,TabPanel 组件是一个容器,它通过卡片布局组织子标签页,这意味着用户将一次看到一个活动标签页的内容。在items配置内部声明的每个子项都是tab类的实例(Ext.tab.Tab),它可以是我们#5行中具有的任何类型。因此,为了不显示空屏幕,我们显示了一个'Home'标签页(#8),它是一个面板(#5),这意味着它也可以包含工具栏和其他组件。这个Home标签页不能被关闭(#6);否则,用户将在主屏幕中间看到一个空白区域,我们不希望这样。我们还在#7行设置了一个格式为home的 Font Awesome 图标,使其看起来更美观。

您可以使用此主页选项卡来显示公告或表现得像一个仪表板,用户将看到所有待办任务的摘要。

创建页脚

下一步是创建主屏幕的页脚。我们将在app/view/main内部创建一个名为Footer.js的新文件,并在其中包含以下代码:

Ext.define('Packt.view.main.Footer', {
    extend: 'Ext.container.Container', //#1
    xtype: 'appfooter',                //#2

    cls: 'app-footer',                 //#3

    height: 30,                        //#4

    layout: 'center',                  //#5

    items: [
        {
            xtype: 'component',               //#6
            width: 350,                       //#7
            componentCls: 'app-footer-title', //#8
            bind: {
                html: '{footer}'              //#9
            }
        }
    ]
});

Footer将扩展自Container类(#1)。Container类是我们能创建的最轻组件,它可以包含项目。我们应该尽可能优先使用它。我们将在稍后进行更详细的讨论。然后,我们声明xtype类(#2),这是我们用于在Main类中实例化此类的别名。

小贴士

总是记住,别名(xtype)的约定是使用所有小写字母。如果您愿意,可以根据个人喜好使用"-"(连字符)分隔单词。

如果我们查看本章的第一幅图像,我们会注意到页脚有一个顶部边框。我们在#3行添加了一个样式,将此边框添加到页脚。cls配置允许我们在 Ext JS 中的组件上添加额外的 CSS。它适用于所有组件。我们将在一分钟内将样式添加到我们的 CSS 中。

由于我们在Main类的南部区域声明了Footer,因此您需要设置height参数。我们可以在Footer类内部或声明南部区域时在Main类内部完成此操作。在这种情况下,我们是在Footer类内部设置它(#4)。

Footer类内部,我们在这个例子中只想有一个组件,即文本(您可以使用版权信息),我们将使用 HTML 显示它。我们还希望这段文本居中。因此,我们可以使用center布局(#5)。要使用center布局,父容器需要只有一个子组件(因为它继承自fit布局,它也只支持单个子组件)。还需要声明子组件的width参数(#7);在这种情况下,我们将显示的文本大约宽350像素。

注意

在 Ext JS 4 中,Center 布局被用作与 Ext JS SDK 一起提供的 UX 插件。在 Ext JS 5 中,这个布局被提升为原生 API,但保持了向后兼容性。在布局配置中,你可以使用center(在 Ext JS 5 中引入)并继续使用ux.center(来自 Ext JS 4),如果你正在将应用程序从 Ext JS 4 迁移到 5。

为了渲染 HTML,我们将使用尽可能轻量和简单的组件,即component#6)。因为我们还想要应用一些 CSS 到我们的文本上,我们将使用componentCls#8)类,这是一个添加到组件根级元素的 CSS 类。

注意,在Footer类内部没有声明任何文本。相反,我们将html配置绑定到一个名为footer的值(#9)。这也是我们将在本章中使用的新的 MVVM 架构的一部分。在前一章中,我们只使用了ViewViewController类来实现登录功能。在本章中,我们将使用完整的特性:从 MVVM 架构中来的 View、ViewController 和 ViewModel(当我们创建项目时,Sencha Cmd 已经生成了这些类,所以最好重用它们!)。目前,请记住,这是 ModelView 绑定的一个部分,我们将在下一个主题中深入探讨。

关于模块化 CSS 的简要说明

让我们讨论另一种向我们的应用程序添加 CSS 的方法。我们已经知道,向我们的应用程序添加 CSS 的最佳实践是在sass/etc文件夹内使用 Sass,就像我们在前面的例子中所做的那样。然而,有一些样式是为特定组件创建的,我们不会在整个应用程序中重用它们。而不是将这些 CSS 样式添加到我们的all.scss文件中,并得到一个可能会在需要维护时给我们带来头痛的大文件,我们可以使用更模块化的 CSS 方法来为我们的 Ext JS 视图创建特定的 CSS。

sass文件夹内,创建一个名为src的新文件夹(如果 Sencha Cmd 没有自动创建),然后在src内部创建一个名为view的新文件夹。在view内部,创建一个名为main的新文件夹。我们将有sass/src/view/main这个目录。在这个目录内,创建一个名为Footer.scss的文件,并在其中包含以下内容:

$packt-footer-text-color: rgb(11, 103, 196); //#1

.app-footer-title {
  color: $packt-footer-text-color; //#2
  font-size: 12px;
  font-weight: bold;
}

.app-footer {
  border-top: 1px solid darken($packt-footer-text-color, 15); //#3
}

在行#1中,我们声明了一个带有蓝色调的 Sass 变量(与TabPanel背景相同的蓝色)。我们在创建用于我们的Footer类的样式时,在行#2#3中重用了这个变量。

注意

在行#3中,我们使用了 Sass 中的darken函数,它接受一个颜色和一个 0-100 之间的数字,这是我们想要使颜色变暗的百分比。有关更多信息,请参阅 Sass 文档,网址为goo.gl/JsAnVz

view/main/Footer.scss 文件与 view/main/Footer.js 文件的路径相同。请注意,这样,维护 Footer 类特定的样式就更容易了。我们将在下一个主题中为 Header 类做同样的事情。我们将 CSS 分离成模块,以便更容易阅读和维护,当我们进行构建时,所有的 CSS 都将连接成一个单独的生产 CSS 文件——这被称为模块化 CSS。看看,使用 Ext JS 开发应用程序不仅仅是 Ext JS;我们还可以应用其他前端技术的知识!

创建 Header 类

接下来,我们将创建 Header 类。Header 类包含应用程序的标志、应用程序名称、提供翻译功能的下拉菜单以及注销按钮。为了创建标题,我们将在 app/view/main 文件夹内创建一个新的文件,名为 Header.js,并包含以下代码:

Ext.define('Packt.view.main.Header', {
    extend: 'Ext.toolbar.Toolbar', //#1
    xtype: 'appheader',            //#2

    requires: [
        'Packt.view.locale.Translation' //#3
    ],

    ui: 'footer',                       //#4

    items: [{
            xtype: 'component',         //#5
            bind: {                     //#6
                html: '{appHeaderIcon}' 
            }
        },{
            xtype: 'component',
            componentCls: 'app-header-title', //#7
            bind: {                           //#8
                html: '{appName}'
            }
        },{
            xtype: 'tbfill'           //#9
        },{
            xtype: 'translation'      //#10
        },{
            xtype: 'tbseparator'      //#11
        },{
            xtype: 'button',          //#12
            itemId: 'logout',         //#13
            text: 'Logout',         
            reference: 'logout',      //#14
            iconCls: 'fa fa-sign-out fa-lg buttonIcon', //#15
            listeners: {  
                click: 'onLogout'  //#16
            }
        }
    ]
});

我们的 Header 类将扩展 Toolbar 类(#1)。Toolbar 类通常用于面板及其子类(网格、表单、树)中,用于组织按钮,但它也可以像我们在这个例子中要做的那样,用来包含其他组件。我们将在其他章节中介绍更多关于工具栏的内容。我们还在 Header 类中声明了一个 xtype 类,我们在 Main 类(#2)中引用了这个类。

每当我们使用我们自己创建的 xtype 类时,Ext JS 都无法理解我们试图实例化的组件是什么。因此,我们需要引用我们正在使用的类。例如,在第 #3 行中,我们通过在第 #10 行中使用的 xtype 类引用我们正在实例化的翻译组件的类。我们将在本章后面开发这个组件。

注意

注意,在 Main 类中,我们还没有使用 requires。我们需要回到那里并添加所需的 HeaderFootermain.Panel 类。

ui 配置允许我们为组件使用特定的主题。Toolbar 组件具有配置 ui: 'footer' (#4),这为工具栏提供了透明的背景。footer 值包含在 Ext JS SDK 中,并使工具栏透明。我们将在本书后面讨论主题时创建一些自定义的 ui 配置。

Header 类的前两个子项是图标(#5)和应用程序名称。对于图标,我们将使用 Font Awesome 来显示桌面格式的图标。对于应用程序的标题,我们将使用我们在 Footer 类中使用的相同方法。我们还在使用一个 componentCls 配置(#7)来对其进行样式化。我们从 ViewModel 中获取这两个值(图标-#6 和应用程序名称-#8),我们将在下一分钟进行介绍。

下一个项目是工具栏填充(#9)。此组件将 translation (#10) 和 logout 按钮 (#13) 对齐到右侧,填充 Toolbar 类中间的空间(在应用程序标题和按钮之间)。

注意

除了 { xtype: 'tbfill' },我们还可以使用 '->' 作为快捷方式。

我们还在第 #12 行声明了 logout 按钮。我们将分配 itemId 以便我们可以通过应用程序全局引用此按钮。当我们使用会话监控功能时,我们需要它。itemId 在其作用域内需要是唯一的;在这种情况下,它需要在这个类中是唯一的,但如果它在应用程序级别是唯一的会更好。

由于我们将使用 ViewController 类来处理注销,我们将声明一个 reference (#14) 以便在 ViewController 类内部更容易地检索按钮引用,我们还将声明监听器 (#16),这意味着当我们在 注销 按钮上点击时,ViewController 类中的 onLogout 函数将被执行。

我们还设置了一个来自 Font Awesome 的图标到 Logout 按钮(#15)。默认情况下,图标将具有黑色。按钮文本是白色,我们希望图标与文本颜色相同。因此,我们添加了一个自定义样式(buttonIcon)。

最后,我们有工具栏分隔符在第 #11 行声明。这只是在按钮之间添加一个分隔符("|")。

注意

同样,tbfill,工具栏分隔符,也有一个快捷方式。除了 { xtype: 'tbseparator' },我们还可以使用 '-'

创建头部 CSS

正如我们对 Footer 类所做的那样,我们也将创建一个文件名,Header.scss,在 sass/view/main 文件夹中,其中包含以下内容:

$packt-header-text-color: #2a3f5d;

.app-header-logo {
  color: $packt-header-text-color;
}

.app-header-title {
  padding: 5px 0 5px 3px;
  color: $packt-header-text-color;
  font-size: 18px;
  font-weight: bold;
}

app-header-logo 是为了自定义图标颜色与应用程序标题相同而创建的。我们在两种样式中都使用了 Sass 变量。标题的文本颜色是深蓝色。

自定义 Font Awesome 图标颜色

默认情况下,Font Awesome 图标将以黑色显示。但我们要让一些图标与我们的主题颜色相同。我们可以使用 CSS 来进行这种自定义。

我们已经声明了两种样式来自定义 Font Awesome 图标。第一个是在 Panel 类中(tabIcon),第二个是 注销 按钮(buttonIcon)。因此,我们还需要将这些样式添加到我们的 CSS 中。为了遵循模块化 CSS 方法,让我们在 sass/etc 下创建一个新文件,iconColors.scss,其中包含以下内容:

$packt-button-icon-color: #fff;
$packt-tab-icon-color: rgb(11, 103, 196);

.tabIcon {
  color: $packt-tab-icon-color;
}

.buttonIcon {
  color: $packt-button-icon-color;
}

小贴士

如果我们决定稍后自定义 Ext JS 主题,我们将习惯于使用 Sass 变量来简化我们的工作!

然后,我们只需要将此文件导入 all.scss 文件中:

@import "fontAwesome/font-awesome";
@import "iconColors";

主屏幕和 MVVM

现在,是时候将所有内容整合在一起了。让我们看看完整的代码如何使 Main 类看起来:

Ext.define('Packt.view.main.Main', {
    extend: 'Ext.container.Container',
    plugins: 'viewport',
    xtype: 'app-main',

    requires: [ //#1        'Packt.view.main.Header',        'Packt.view.main.Footer',        'Packt.view.main.Panel',        'Packt.view.main.MainController',        'Packt.view.main.MainModel'    ],

    controller: 'main', //#2
    viewModel: {
        type: 'main' //#3
    },

    layout: {
        type: 'border'
    },

    items: [{
        region: 'center',
        xtype: 'mainpanel'
    },{
        xtype: 'appheader',
        region: 'north'
    },{
        xtype: 'appfooter',
        region: 'south'
    },{
        xtype: 'container',
        region: 'west',
        width: 200,
        split: true
    }]
});

我们需要添加我们创建的所有类(我们通过它们的 xtype 类引用它们)的 requires 声明,以及由 Sencha Cmd 创建的 Main ViewModel 和 Main ViewController 类。

controller#2)和 viewModel#3)声明已经在创建项目时由 Sencha Cmd 添加。我们通过引用它们的类型简单地重用它们。

主要 ViewModel

如果我们打开 app/view/main 文件夹中的 MainModel.js 文件,我们将在其中看到一些内容。我们将向其中添加更多内容,文件将如下所示:

Ext.define('Packt.view.main.MainModel', { //#1
    extend: 'Ext.app.ViewModel', //#2

    alias: 'viewmodel.main', //#3

    data: {
        name: 'Packt', //#4
        appName: 'DVD Rental Store', //#5
        appHeaderIcon: '<span class="fa fa-desktop fa-lg app-header-logo">', //#6
        footer: 'Mastering ExtJS book - Loiane Groner - http://packtpub.com' //#7
    }
});

让我们从类的名称(#1)开始。由 Sencha 提议的 ViewModel 命名规范是视图的名称(Main)+ "Model",结果为 MainModel。ViewModel 从 Ext JS 5 中引入的 ViewModel 类(#2)扩展,并使用 MVVM 架构。ViewModel 的别名(#3)由 "viewmodel." 加上我们想要分配的类型名称定义。在这种情况下,Sencha 已经为我们创建了此类,类型为 main。这就是为什么我们可以在 View 类中使用以下代码引用此别名:

viewModel: {
    type: 'main'
}

data 配置允许我们在 ViewModel 类中填充值。name 字段(#4)是由 Sencha Cmd 创建的,所以我们将保留它(如果你想移除,也可以移除)。appName#5)和 appHeaderIcon#6)属性被 Header 使用,而 footer#7)被 Footer 类使用。

MainModelMain 类(视图)绑定。因为 HeaderFooterMain 组件的 items,它们也可以引用 MainModel

这是我们创建带有预填充数据的 ViewModel 类的最简单方式。我们将在本书的其余部分提供其他高级示例,但我们需要从小步骤开始!

注意

关于 ViewModel、数据绑定以及如何绑定不同数据类型的信息,请阅读以下 Sencha 指南:goo.gl/qta6kH

注销功能

由于用户可以选择登录应用程序,用户也可以从应用程序中注销。在 Header 类中,我们已经声明了 logout 按钮。唯一待办的事情是在 MainController 中实现监听器。

由于 MainController 类是由 Sencha Cmd 创建的,我们正在重用它。文件中已经有了一些代码。让我们移除由 Sencha 创建的任何监听器。MainController 将如下所示:

Ext.define('Packt.view.main.MainController', {
    extend: 'Ext.app.ViewController',

    requires: [
        'Ext.MessageBox'
    ],

    alias: 'controller.main',

 //we will insert code here
});

Header 类中,我们声明了 logout 按钮、其引用和其监听器。因此,我们需要实现 onLogout 函数,如下所示:

onLogout: function(button, e, options){

    var me = this;      //#1
    Ext.Ajax.request({
        url: 'php/security/logout.php', //#2
        scope: me,                      //#3
        success: 'onLogoutSuccess',     //#4
        failure: 'onLogoutFailure'      //#5
    });
},

me (#1) 变量是对 this 的引用,即 MainController 类。我们将向 php/security/logout.php 发起一个 Ajax 调用 (#2)(我们很快将创建此文件)。我们将在 MainController 类内部声明的单独函数中处理 success (#4) 和 failure (#5) 回调。这就是为什么作用域被设置为 MainController 类本身 (#3) 的原因。

小贴士

我们可以直接在 Ajax 请求中声明 successfailure 回调。但这样,我们的代码会非常长,这会降低其可读性。这种方式可以使代码保持组织结构,更容易阅读。这始终是一个需要遵循的最佳实践。

处理服务器上的注销

为了处理服务器上的注销功能,我们将在 php/security 文件夹下创建一个名为 logout.php 的新 PHP 页面。代码非常简单:

<?php

session_start(); // #1

$_SESSION = array(); // #2

session_destroy(); // #3

$result = array(); // #4

$result['success'] = true;
$result['msg'] = 'logout';

echo json_encode($result); // #5

首先,我们需要恢复当前会话 (#1),然后我们需要取消所有会话变量 (#2),接下来我们需要销毁会话 (#3)。最后,我们需要将信息发送回 Ext JS,表明会话已被销毁 (#4#5)。

Ajax 请求成功与失败

我们已经处理了服务器端代码。现在,我们需要回到 Ext JS 代码,并处理来自服务器的响应。但首先,我们需要理解一个通常让大多数 Ext JS 开发者感到困惑的重要概念。

在 第三章,登录页面 中,我们提到 Ext JS 处理表单提交和 Ajax 请求的 成功 x 失败 方式略有不同,这正是大多数开发者感到困惑的地方。

Ext.Ajax 类负责 Ext JS 执行的 Ajax 请求。如果我们查看文档,这个类有三个事件:beforerequestrequestcompleterequestexception,具体解释如下:

  • 事件 beforerequest 在请求之前触发

  • 当 Ext JS 能够从服务器获取响应时,会触发事件 requestcomplete

  • 当服务器返回 HTTP 错误状态时,会触发 requestexception 事件

现在,让我们回到 Ext.Ajax.request 调用。我们可以向请求传递一些选项,包括我们想要连接的 url 属性、参数以及其他选项,包括 successfailure 函数。现在,误解就从这里开始了。一些开发者理解,如果服务器上的操作成功执行,我们通常从服务器返回 success = true。如果出现问题,我们返回 success = false。然后,在 success 函数中处理 success = true,在 failure 函数中处理 success = false。这是 错误的,并且这与 Ext JS Ajax 请求的工作方式不符;然而,这正是表单请求的工作方式(正如我们在 第三章,登录页面 中所学的)。看看它如何变得混乱?

对于 Ext JS Ajax 请求,success是指服务器返回响应(success truefalse;这并不重要),而failure是指服务器返回 HTTP 错误状态。这意味着如果服务器能够返回响应,我们将在success函数中处理这个响应(无论success信息是true还是false),在failure消息中,我们需要通知用户出了问题,用户应联系系统管理员。

我们将首先实现failure回调函数。因此,在ViewController类中,我们将添加以下代码:

onLogoutFailure: function(conn, response, options, eOpts){
    Packt.util.Util.showErrorMsg(conn.responseText);
},

我们将要执行的操作是向用户显示一个带有错误图标和确定按钮的警告框,并包含 HTTP 状态错误信息。

为了重现错误以便触发requestexception事件,我们可以在测试目的下将logout.php文件重命名为其他名称(例如,logout_.php)。然后,我们执行代码,将得到以下输出:

Ajax 请求成功与失败对比

这就是failure函数所需的所有内容。请注意,我们正在重用我们在第三章中开发的Packt.util.Util类,在本章中再次使用!看看重用代码有多方便?

注意

我们正在重用代码并节省了一些重复的代码行。我们还在项目中创建了一种处理一些事情的模式。这在项目中工作尤其重要,尤其是在团队中工作。这样,项目看起来就像是一个人开发的,而不是多个人开发的,这非常好。这也是一个需要遵循的最佳实践。重用代码也是所谓的最小化有效载荷大小的一部分,这是使用 JavaScript 开发时的一个最佳实践,也是 Web 开发的一个关注点。要了解更多信息,请访问developers.google.com/speed/docs/best-practices/payload

为了使代码正常工作,请从MainController类中移除以下代码:

requires: [
    'Ext.MessageBox'
],

在前述代码的位置编写以下代码:

requires: [
 'Packt.util.Util'
],

现在,让我们专注于success回调函数:

onLogoutSuccess: function(conn, response, options, eOpts){
   //#1
    var result = Packt.util.Util.decodeJSON(conn.responseText);

    if (result.success) { //#2
        this.getView().destroy(); //#3
        window.location.reload(); //#4
    } else {

        Packt.util.Util.showErrorMsg(result.msg); //#5
    }
}

我们需要做的第一件事是解码从服务器接收到的 JSON 消息(#1)。如果我们记录发送到success函数的conn参数(console.log(conn)),我们将在控制台得到以下输出:

Ajax 请求成功与失败对比

我们想要检索的信息位于conn.responseText属性中,即successmsg值。回想一下,在第三章中,我们讨论了responseText可能包含除我们期望的 JSON 以外的异常的可能性。因此,出于这个原因,我们将重用我们创建的decodeJSON函数(#1),以便我们可以正确处理任何结果。

success#2)的情况下,我们将destroy``Main类(#3),即我们的 Viewport(这样做可以释放浏览器的内存,并使对象可供 JavaScript 垃圾回收器回收)。由于 Viewport 包含我们应用程序的所有其他组件,它将销毁它们。然后,我们将重新加载应用程序,再次显示登录屏幕(#4)。

如果successfalse(或发生任何错误),我们将显示一个带有错误消息的错误警报(#5)。

客户端活动监控

让我们进一步增强我们的应用程序。让用户知道网络应用程序有超时,他们不能整天都让它开着——主要是出于安全考虑。服务器端语言也有超时。一旦用户登录,服务器将不会永远可用。这也是出于安全考虑。这就是为什么我们需要将这种功能添加到我们的应用程序中。

我们将使用一个插件来完成这个任务。这个插件叫做Packt.util.SessionMonitor,它基于 Sencha Market(market.sencha.com/extensions/extjs-activity-monitor)的活动监控插件。在一段时间(默认为 15 分钟的空闲时间)后,插件将向用户显示一条消息,询问用户是否希望保持会话活跃。如果用户选择是,那么它将向服务器发送一个 Ajax 请求以保持服务器会话活跃。如果用户在消息显示 60 秒后没有任何操作,应用程序将自动注销。

您可以从github.com/loiane/masteringextjs/blob/master/app/util/SessionMonitor.js获取此插件的源代码。它在 Ext JS 4 和 Ext JS 5 中工作。

在前一个 URL 中的Ext.ComponentQuery.query的第53行,我们将更改logout按钮选择器为button#logout,这是我们为logout按钮创建的选择器(这就是为什么我们为logout按钮创建了itemId)。

此外,我们将更改第42行 Ajax 请求的url属性,将其更改为前一个 URL 中的php/sessionAlive.php

如果我们要更改非活动间隔,我们只需要更改maxInactive配置。

要开始监控会话,我们只需要在LoginControlleronLoginSuccess方法中添加这一行代码,在我们实例化Main类之后立即进行:

onLoginSuccess: function(form, action) {
    Ext.getBody().unmask();
    this.getView().close();
    Ext.create('Packt.view.main.Main');
 Packt.util.SessionMonitor.start();
} 

我们不能忘记在 LoginController 中也将 'Packt.util.SessionMonitor' 添加到 requires 中。

php/sessionAlive.php 文件中,我们将有以下的代码:

<?php
session_start();

这只是为了保持服务器会话活跃,并且将会话计时器重置为 15 分钟。

如果我们运行这段代码并等待不活动时间(15 分钟——当然我们可以将 maxInactive 参数改为等待更短的时间),我们将看到如下信息:

客户端活动监控器

Ext JS 并没有提供这个功能的原生支持。但是,正如我们所看到的,实现它非常简单,我们可以将这个插件重用于我们正在工作的所有 Ext JS 项目中。

多语言功能

有时候,你可能想要将你正在海外工作的项目或产品发送出去,因此拥有翻译功能非常重要。毕竟,并不是每个人都理解或说与你相同的语言。这正是我们将在这个主题中要实现的内容:一个多语言组件,我们可以用它来翻译这个项目的标签。所以,在这个主题的结尾,这将是我们输出的结果:

多语言功能

理念是将用户的语言偏好本地存储,这样当用户下次加载应用程序时,首选语言将自动设置。并且当用户更改语言时,应用程序需要重新加载,以便将新的翻译加载到内存中。

创建更改语言组件

如果我们查看这个主题开头展示的截图,我们可以注意到多语言组件是一个按钮,当我们点击箭头时,会弹出一个包含可用语言的菜单。

带有箭头的按钮是一个分割按钮组件,它有一个菜单,每个语言选项都是菜单的一个菜单项。所以,让我们继续创建一个名为 Packt.view.locale.Translation 的新类,包含我们描述的特性。

小贴士

每当我们命名一个 View 类(也称为 Ext JS 小部件或组件)时,给出一个可以快速提醒我们该类做什么的名字是很好的。例如,通过将类命名为 locale.Translation,我们可以快速知道这个类提供了本地化应用程序的能力。

我们需要在 app/view/locale 文件夹下创建一个名为 Translation.js 的新文件,并在其中包含以下代码:

Ext.define('Packt.view.locale.Translation', {
    extend: 'Ext.button.Split',   //#1
    xtype: 'translation',         //#2

    menu: {               //#3
        xtype: 'menu',    //#4
        items: [
            {
                xtype: 'menuitem', //#5
                iconCls: 'en',
                text: 'English'
            },
            {
                xtype: 'menuitem', //#6
                iconCls: 'es',
                text: 'Español'
            },
            {
                xtype: 'menuitem', //#7
                iconCls: 'pt_BR',
                text: 'Português'
            }
        ]
    }
});

因此,我们创建的类是从分割按钮类(#1)扩展而来的。分割按钮类是一种提供内置下拉箭头的类,它可以独立于按钮的默认点击事件触发事件。通常,这会被用来显示一个下拉菜单,为主要的按钮操作提供额外的选项。我们也将 xtype 类分配给这个类(#2),我们在 Header 类中用它来实例化它。

然后,在menu#3)配置中,我们需要创建menu类的实例(#4),然后是menu类的menuitems,它们将代表每个区域选项。所以我们有:一个翻译成英语的选项(#5)——它还将显示美国国旗(en);一个翻译成西班牙语的选项(#6)——它还将显示西班牙国旗(es);还有一个翻译成葡萄牙语的选项(#7)——它还将显示巴西国旗(pt_BR)。

我们可以添加我们需要的任何选项。对于每个翻译选项,我们只需要为menu类添加新的menuitems

添加 CSS – 国家旗帜

下一步现在是要添加iconCls的 CSS,我们在应用程序 CSS 的翻译组件中使用了它。为了使我们的代码更加有序(并为添加更多语言和更多旗帜图标留出空间),我们将在sass/etc文件夹内创建一个名为flagIcons.scss的新文件,其内容如下:

.pt_BR {
  background-image:url('images/app/flags/br.png') !important;
}

.en {
  background-image:url('images/app/flags/us.png') !important;
}

.es {
  background-image:url('images/app/flags/es.png') !important;
}

注意

注意,样式的名称(pt_BRenes)与我们为每个menuitem使用的iconCls属性相同。这非常重要。

all.scss文件中,我们需要导入我们创建的文件。在第二行添加以下代码(在我们导入 Font Awesome 文件之后):

@import "flagIcons";

但图标怎么办?Font Awesome 没有旗帜图标。我们将使用 FamFamFam 的旗帜集([www.famfamfam.com/](http://www.famfamfam.com/)),这些图标可以免费用于任何目的(Creative Commons License)。在resources/images/app目录下创建一个名为flags的文件夹,并将旗帜图标复制粘贴进去。你可能需要将它们的名称更改为符合我们这个例子中使用的名称。

使用翻译组件

现在,translation组件已经准备好了(只显示给用户的内容)。我们将在项目的两个地方使用translation组件:在登录屏幕上,以及在Header上的注销按钮之前(它已经就位)。

让我们将其添加到登录屏幕。再次打开Packt.view.login.Login类。在工具栏上,我们将将其作为第一个项目添加,以便它看起来与我们在这个主题开头所展示的截图完全一样:

items: [
  {      xtype: 'translation'  },
  {
      xtype: 'tbfill'
  },
  //...
]

我们不能忘记将类添加到登录屏幕类的requires声明中:

requires: [
    ' Packt.view.locale.Translation '
],

如果我们重新加载应用程序(在做出所有这些更改的同时,不要忘记在终端上执行sencha app watch),我们将能够看到到目前为止我们所开发的内容。

创建多语言文件

我们需要在我们的项目中某个地方存储翻译。我们打算在resources/locale文件夹内的 JavaScript 文件中存储每种语言的翻译。由于我们将使用iconCls作为 ID 来加载翻译文件,我们需要创建三个文件:en.jses.jspt_BR.js。在每个文件中,我们将创建一个名为translations的 JavaScript 对象,并且该对象的每个属性都将是一个翻译。所有翻译文件必须相同;唯一不同的是将包含翻译的每个属性的值。

例如,以下代码是针对en.js文件的:

translations = {
    login: "Login",
    user: "User",
    password: "Password",

    cancel: "Cancel",
    submit: "Submit",
    logout: 'Logout',

    capsLockTitle: 'Caps Lock is On',
    capsLockMsg1: 'Having Caps Lock on may cause you to ',
    capsLockMsg2: 'enter your password incorrectly.',
    capsLockMsg3: 'You should press Caps Lock to turn it ',
    capsLockMsg4: 'off before entering your password.'
};

以下代码是针对pt_BR.js的,其中包含巴西葡萄牙语的翻译:

translations = {
    login: "Login",
    user: "Usuário",
    password: "Senha",

    cancel: "Cancelar",
    submit: "Enviar",
    logout: 'Logout',

    capsLockTitle: 'Caps Lock está ativada',
    capsLockMsg1: 'Se Capslock estiver ativado, isso pode fazer ',
    capsLockMsg2: 'com que você digite a senha incorretamente.',
    capsLockMsg3: 'Você deve pressionar a tecla Caps lock para ',
    capsLockMsg4: 'desativá-la antes de digitar a senha.'
};

以下代码是es.js代码,其中包含西班牙语的翻译:

translations = {
    login: "Login",
    user: "Usuario",
    password: "Contraseña",

    cancel: "Cancelar",
    submit: "Enviar",
    logout: 'Logout',

    capsLockTitle:'Bloq Mayús está Activado',
    capsLockMsg1:'Tener el Bloq Mayús activado puede causar que ',
    capsLockMsg2:'introduzca su contraseña de forma incorrecta.',
    capsLockMsg3:'Usted debe presionar Bloq Mayús para apagarlo ',
    capsLockMsg4:'antes de escribir la contraseña.'
};

如我们所见,文件是相同的;然而,翻译是不同的。随着应用程序的增长,我们将向其中添加更多翻译,并且保持文件以相同的方式组织是一个好习惯,以便将来便于更改任何翻译。

在应用程序组件上应用翻译

要将翻译应用于我们至今开发的组件,非常简单:我们需要使用我们创建的translations字典来代替将要表示标签的字符串。

例如,在Packt.view.view.Login类中,我们有关窗口的标题,usernamepassword字段的fieldLabel,以及取消提交按钮的文本。标签是硬编码的,我们希望从翻译文件中获取翻译。

因此,我们需要将:Login窗口的title替换为以下内容:

title: translations.login,

我们需要将username textfieldfieldLabel替换为以下内容:

fieldLabel: translations.user,

我们需要将password textfieldfieldLabel替换为以下内容:

fieldLabel: translations.password,

我们需要将取消按钮的text替换为以下内容:

text: translations.cancel

我们需要将提交按钮的text替换为以下内容:

text: translations.submit

等等。我们还可以将翻译应用于注销按钮和CapsLockTooltip类。

HTML5 本地存储

我们对translate组件的想法是将用户的语言偏好存储在某个地方。我们可以使用 cookie 来做这件事,但我们想要的非常简单,cookie 包含在每个 HTTP 请求中。我们希望长期存储此信息,并使用可以持久化超过页面刷新或用户关闭浏览器的情况的东西。而完美的选择是使用 HTML5 的新特性之一——本地存储。

Ext JS 支持本地存储;它可以与LocalStorageProxy一起使用,但我们需要更简单的东西,直接在代码中使用 HTML5 功能会更简单。这也展示了我们可以与其他 API 一起使用 Ext JS API。

本地存储不是每个浏览器都支持的;它仅由 IE 8.0+、Firefox 3.5+、Safari 4.0+、Chrome 4.0+、Opera 10.5+、iPhone 2.0+ 和 Android 2.0+ 支持。我们将在本书稍后部分构建一个警告用户升级浏览器的页面。我们还将使用其他 HTML5 功能以及 Ext JS 在其他屏幕上。因此,现在我们需要知道,我们现在要实现的这个代码并不适用于每个浏览器。

注意

如需了解更多关于 HTML5 存储的信息,请访问 http://diveintohtml5.info/storage.html

我们希望这段代码在我们实例化 Ext JS 应用程序之前加载。因此,出于这个原因,我们将在 app/Application.js 文件中的 Ext.define('Packt.Application', { 之前添加它:

function loadLocale(){

    var lang = localStorage ? (localStorage.getItem('user-lang') || 'en') : 'en',
        file = Ext.util.Format.format("resources/locale/{0}.js", lang);

    Ext.Loader.loadScript({url: file, onError: function(){
        alert('Error loading locale file. Please contact system administrator.');
    }});
}

loadLocale(); //#1

因此,首先,我们将验证 localStorage 是否可用。如果可用,我们将检查是否在 localStorage 上存储了一个名为 user-lang 的项;如果没有,英语将是默认语言。即使 localStorage 不可用,英语也将被设置为默认语言。

然后,我们创建一个名为 file 的变量,该变量将接收必须由应用程序加载的翻译文件的路径。

提示

Ext JS 有一个名为 Ext.util.Format 的类,其中包含一个静态方法 format,该方法将字符串和作为标记传递的值连接起来,在这种情况下是 lang。这比在 JavaScript 中手动进行字符串连接要干净。

在我们将 url 格式化后,我们将使用 Ext.Loader 加载它。loadScript 方法加载指定的脚本 URL,并调用提供的回调函数(如果有)。它接受 onLoadonError 回调函数。在我们的情况下,不需要成功回调(onLoad)。如果在加载区域文件时发生任何错误,应用程序将无法加载,因此 onError 回调函数在这个情况下很有趣且是必需的,以便在发生错误时用户可以联系支持(尝试将 en.js 文件重命名以模拟错误)。

为了避免创建全局变量(因为这不是一个好的 JavaScript 实践),我们将我们的代码包装在一个函数中。因此,我们需要在 Ext.define('Packt.Application' 之前调用这个函数(#1)。

当我们的应用程序加载完毕时,它将包含所有可用的翻译。

实时处理更改语言

现在是 translation 组件代码的最后一部分。当用户选择不同的语言时,我们需要重新加载应用程序,以便 loadLocale 函数再次执行并加载用户选择的新语言。

为了做到这一点,我们将在我们的应用程序中创建一个新的 Controller,专门处理翻译组件。这里的问题是:我们现在使用 MVC(我们将在下一章中介绍)还是 MVVM?答案取决于您的个人喜好。为了这个功能,我们将继续使用 MVVM,或者更确切地说,使用 ViewController,原因很简单:这两个文件(TranslationController.jsTranslation.js)都位于同一个目录(app/view/locale)中。这意味着将这个组件复制粘贴到其他项目中使用会更简单(我们可以整体复制locale文件夹)。

因此,我们需要创建一个名为Packt.view.locale.TranslationController的新类,为了创建这个类,我们需要在app/view/locale文件夹下创建一个名为TranslationController.js的新文件。在这个控制器中,我们需要监听两个事件:一个由translation组件本身触发,另一个由menuitems触发:

Ext.define('Packt.view.locale.TranslationController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.translation'
});

让我们回到Translation.js文件,并将TranslationController添加为 ViewController,这样我们就可以开始监听事件:

requires: [
    'Packt.view.locale.TranslationController'
],

controller: 'translation',

分割按钮有两个事件,即click(由于它是一个按钮而触发)和arrowclick事件,当用户点击箭头时触发。我们对这两个事件都不感兴趣。在分割按钮内部,有一个Menu类,其中包含menuitems,每个menuitem代表一个区域文件。MenuItem组件在点击时也会触发click事件。因此,我们可以为每个MenuItem添加click监听器——或者更好的是,为menu添加一个defaults配置,如下所示(这将应用于所有项目):

xtype: 'menu',
defaults:{    listeners: {        click: 'onMenuItemClick'    }},

现在,我们可以回到TranslationController并实现onMenuItemClick方法,如下所示:

onMenuItemClick: function(item, e, options){

    var menu = this.getView(); //#1

    menu.setIconCls(item.iconCls); //#2
    menu.setText(item.text);       //#3

    localStorage.setItem("user-lang", item.iconCls); //#4

    window.location.reload(); //#5
}

首先,我们将获取对translation组件的引用(#1)。然后,我们将更新分割按钮的iconClstext属性,使其与所选菜单项的iconClstext属性相同(#2#3)。接下来,我们将更新用户在localStorage上选择的新语言(#4),最后,我们将要求浏览器重新加载应用程序(#5)。

ViewController 的早期生活

还有一个细节需要注意。当加载应用程序时,translation组件没有配置文本或图标。我们还需要注意这一点。我们可以监听beforerenderrender事件,在组件显示给用户之前更新这两个属性,但有一个非常重要的细节:ViewController 在组件生命周期的早期就已经创建,因此无法监听这些事件。

根据 Sencha 文档,我们可以使用三种方法在组件生命周期的关键点执行一些任务:

  • beforeInit:可以通过覆盖此方法来在调用 initComponent 方法之前对视图进行操作。此方法在控制器创建后立即调用,发生在从组件构造函数中调用的 initConfig 期间。

  • Init:在调用 initComponent 后不久在视图中调用。这是为控制器执行初始化的典型时间,因为视图已初始化。

  • initViewModel:当视图的 ViewModel 被创建时(如果已定义),将调用此方法。

由于我们希望 translation 组件在渲染时具有 iconClstext,因此我们可以使用 TranslationController 中的 init 方法来为我们执行此逻辑:

init: function() {
    var lang = localStorage ? (localStorage.getItem('user-lang') || 'en') : 'en',
        button = this.getView();

    button.setIconCls(lang); //#1

    if (lang == 'en'){       //#2
        button.setText('English');
    } else if (lang == 'es'){
        button.setText('Español');
    } else {
        button.setText('Português');
    }
}

首先,我们将验证是否存在 localStorage,如果存在,我们将获取存储的语言。如果没有 localStorage,或者首选语言尚未存储(用户首次使用应用程序或用户尚未更改语言),则默认语言为 English。然后,我们将设置分割按钮的 iconCls 为所选语言的标志(#1)。

如果选择的语言是英语,我们将设置分割按钮的 text"English" (#2),如果选择的语言是西班牙语,我们将设置分割按钮的 text"Español" (#8);否则,我们将设置文本为 "Português"(葡萄牙语)。

注意

此控制器也适用于 MVC 架构。您可以查看 MVC 和 MVVM 实现之间的差异,请参阅goo.gl/ajaIao

如果我们运行应用程序,我们可以更改首选语言,并看到结果是已翻译的应用程序,如下所示:

ViewController 的早期生活

使用区域文件翻译 Ext JS

如同往常,还有一件事尚未完成。我们只翻译了应用程序的标签。表单错误和其他作为 Ext JS API 部分的消息没有翻译。Ext JS 提供了区域文件支持。我们所需做的只是将 JavaScript 区域文件添加到 HTML 页面中。为此,我们将在 Application.js 文件中的 loadLocale 函数内添加以下代码:

var extJsFile = Ext.util.Format.format("ext/packages/ext-locale/build/ext-locale-{0}.js", lang);
Ext.Loader.loadScript({url: extJsFile});

提示

用于标志和翻译文件(en.jses.jspt_BR.js)的 iconCls 名称是由于 Sencha 使用的区域文件名称。因此,在命名自己的文件之前,请确保您已验证 Sencha 使用的名称。

现在,如果我们再次尝试运行应用程序,我们将能够看到所有 Ext JS 消息也将被翻译。例如,如果我们更改翻译为西班牙语,表单验证错误也将是西班牙语:

使用区域文件翻译 Ext JS

现在,应用程序的区域支持已完成!

提示

你可能想要在应用本地化支持后检查标签大小(使用labelWidth配置来更改其默认的 100 像素大小)和屏幕的消息目标。例如,标签Contraseña在屏幕上比Password需要更多的宽度。

在应用本地化后,将Login类中的labelWidth更改为70。你可以将msgTarget更改为'side'或增加窗口的height,以便在运行时以其他语言正确显示表单验证消息。

摘要

我们介绍了如何使用 Border 布局实现本书将要实现的应用程序的基础,你学习了如何实现一个注销按钮(在 Ext JS 端和服务器端)。我们还介绍了客户端活动监控和会话超时功能。

最后,你学习了如何使用 HTML5 功能和 Ext JS 构建一个翻译组件,该组件能够翻译应用程序的所有标签,并在运行时更改首选语言。你还学习了如何使用 Ext JS 的本地化支持来翻译框架的消息和标签。

在本章中,我们在应用程序的实现方面取得了重大进展。我们创建了新的文件,我们的应用程序正在增长。在本书的下一章中,我们将继续创建更多的文件和组件。

在下一章中,我们将学习如何使用折叠面板和树构建一个动态菜单。

第五章. 高级动态菜单

我们已经实现了登录功能,这在第三章“登录页面”中进行了实现,以及应用程序的基础,这在第四章“注销和多语言功能”中进行了实现。在我们的应用程序基础中,缺少一个部分,那就是菜单。因此,接下来我们将开发的是动态菜单。

一旦用户经过认证,我们将显示应用程序的基本屏幕,它由一个带有 Border 布局的 Viewport 组成。在 Viewport 的左侧,我们将显示一个菜单。这个菜单将是动态的,菜单上显示的项目取决于用户拥有的权限,这就是为什么我们称之为动态菜单。

其中一个选项是渲染系统的所有屏幕,然后根据用户角色隐藏或显示它们。然而,这不是我们在这本书中将要使用的方法。我们将只渲染和显示用户可以访问的屏幕。我们将采用的方法是动态根据用户权限渲染菜单。

因此,在本章中,我们将学习如何使用不同的 Ext JS 组件和布局(我们尚未介绍)来显示动态菜单。总结来说,在本章中,我们将涵盖:

  • 使用 Accordion 布局和 TreePanel 创建动态菜单

  • 使用模型关联从服务器加载数据

  • 在服务器上处理动态菜单

  • 动态打开菜单项

  • 使用 MVC 架构

动态菜单概述

因此,在本章中,我们将首先实现动态菜单。我们本可以使用仅 TreePanel 来显示菜单,但我们喜欢挑战,并希望为用户提供最佳体验。因此,我们将使用 Accordion 布局和 TreePanels 来实现动态菜单,从而得到一个更高级的动态菜单。

我们的系统由模块组成,每个模块都有子项,这些子项是系统的屏幕。一个 Accordion 面板将代表包含所有模块的菜单;这样,用户可以展开以一次查看每个模块的选项。而对于每个模块的选项,我们将使用 TreePanel;菜单的每个选项都将来自 TreePanel 的一个节点。

因此,在本主题结束时,我们将拥有如下截图所示的动态菜单:

动态菜单概述

在我们开始动态菜单之前,让我们快速了解一下 Ext JS TreePanel 组件的工作原理以及 Accordion 布局的工作原理。首先理解这两个概念将有助于我们理解动态菜单的实现方式。

Ext JS TreePanel

树形面板是显示应用程序中层次数据的完美组件。这就是为什么我们将使用它来显示菜单。以下图像展示了树形面板及其组成部分:

Ext JS 树形面板

如我们在第一章中学习的,Sencha Ext JS 概述,树形面板扩展自Ext.panel.Table类,同样,网格面板也是如此。Ext.panel.Table类扩展自Panel类。所有Panel类都有一个外壳,即面板本身,这允许我们设置标题并添加工具栏,还可以添加子项。

负责显示数据的部分称为视图,它是Ext.view.View类型,并放置在Panel容器内。我们可以通过两种方式将数据设置到树形面板中:使用root配置预定义或使用存储器。

存储器充当我们的数据访问对象DAO)。在这个例子中,我们将从服务器加载数据,所以我们将使用存储器。存储器加载一个我们称之为ModelExt.data.Model)的对象集合。在树形面板的情况下,这些模型被装饰了NodeInterfaceExt.data.NodeInterface)。

注意

关于装饰器模式的更多信息,请访问en.wikipedia.org/wiki/Decorator_pattern

我们可以选择显示或隐藏树形面板的Root。在前面的图像中,Root是可见的(称为Root的节点)。

树形面板的每个节点都可以有子节点(在所需的嵌套级别中)。当一个节点没有任何子节点时,我们称之为叶节点

当我们在本章后面实现菜单时,我们将更深入地探讨树形面板。现在,这些是我们需要熟悉的概念。

手风琴布局

手风琴布局管理多个面板,以可展开的手风琴样式,默认情况下,任何给定时间只能展开一个面板(这可以通过将multi配置设置为true来更改)。只有 Ext 面板和Ext.panel.Panel的所有子类可以在手风琴布局容器中使用。

我们可以使用树形面板单独实现动态菜单,但我们喜欢挑战!此外,从 UI 的角度来看,使用手风琴布局容器将模块分开比简单的树形面板更美观,正如我们在以下图像中可以看到的:

手风琴布局

菜单本身是一个使用手风琴布局的面板。一个树形面板代表菜单的每个模块(注意,由于手风琴布局的能力,你可以展开或折叠每个模块)。我们将从服务器加载所需的数据来显示这个菜单,并且我们将根据用户的权限从数据库中加载数据。这就是为什么我们称之为动态菜单。

注意

关于这种方法的注意事项:我们为每个模块创建一个 TreePanel 用于导航。同时创建许多对象有一些缺点,例如内存消耗。我们也可以创建一个单独的 TreePanel,并将所有模块作为带有子节点的节点显示。有关 JavaScript、内存消耗及其问题的更多信息,请阅读 developer.chrome.com/devtools/docs/javascript-memory-profiling

既然我们已经熟悉了所有概念,我们就可以开始菜单了!

数据库模型 – 组、菜单和权限

我们已经创建了 usergroups 表。为了存储菜单及其选项的信息以及每个组拥有的权限,我们需要创建另外两个表:menupermissions 表,如下截图所示:

数据库模型 – 组、菜单和权限

menu 表中,我们将存储所有的 menu 信息。由于 menu 表的每个选项都将是一个 TreePanel 的节点,我们将以表示 TreePanel 的方式存储信息。因此,我们有一个 id 字段来识别节点,一个 text 字段作为将在节点上显示的文本(在我们的情况下,我们将存储翻译文件的属性,因为我们使用多语言功能),iconCls 代表用于显示每个节点图标的 css 类,className 代表我们将动态实例化和在应用程序中央选项卡面板中打开的类的 alias (xtype),最后是 menu_id 字段,代表 root 节点(如果有的话——模块节点将没有 menu_id 字段,但模块项将有)。

然后,由于 menu 表与 groups 表之间存在 N:N 的关系,我们需要创建一个 permissions 表来表示这种关系。我们将在下一章中学习如何将用户分配到组中。

提示

如果你对数据库关系不熟悉,以下链接提供了一个很好的教程:goo.gl/hXRsPx

因此,要创建新的表,我们将使用以下 SQL 脚本:

USE `sakila` ;

CREATE  TABLE IF NOT EXISTS `sakila`.`menu` (
  `id` INT NOT NULL AUTO_INCREMENT ,
  `text` VARCHAR(45) NOT NULL ,
  `iconCls` VARCHAR(15) NULL ,
  `className` VARCHAR(45) NULL ,
  `menu_id` INT NULL ,
  PRIMARY KEY (`id`) ,
  INDEX `fk_menu_menu1_idx` (`menu_id` ASC) ,
  CONSTRAINT `fk_menu_menu1`
  FOREIGN KEY (`menu_id` )
  REFERENCES `sakila`.`menu` (`id` )
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
  ENGINE = InnoDB;

CREATE  TABLE IF NOT EXISTS `sakila`.`permissions` (
  `menu_id` INT NOT NULL ,
  `groups_id` INT NOT NULL ,
  PRIMARY KEY (`menu_id`, `groups_id`) ,
  INDEX `fk_permissions_groups1_idx` (`groups_id` ASC) ,
  CONSTRAINT `fk_permissions_menu1`
  FOREIGN KEY (`menu_id` )
  REFERENCES `sakila`.`menu` (`id` )
    ON DELETE NO ACTION
    ON UPDATE NO ACTION,
  CONSTRAINT `fk_permissions_groups1`
  FOREIGN KEY (`groups_id` )
  REFERENCES `sakila`.`groups` (`id` )
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
  ENGINE = InnoDB;
});

我们还需要用一些数据填充 menupermissions 表。我们可以使用以下 SQL 代码创建本书中将开发的全部模块和菜单选项。我们还将授予上一章中创建的用户对所有菜单选项的访问权限,因为这个用户是 管理员。以下是封装本段讨论内容的代码:

INSERT INTO `menu` (`id`,`text`,`iconCls`,`className`,`menu_id`) 
VALUES 
(1,'menu1','fa fa-group fa-lg',NULL,NULL),
(2,'menu11','xf0c0','panel',1),
(3,'menu12','xf007','panel',1),
(4,'staticData','fa fa-database fa-lg',NULL,NULL),
(5,'actors','xf005','panel',4),
(6,'categories','xf013','panel',4),
(7,'languages','xf1ab','panel',4),
(8,'cities','xf018','panel',4),
(9,'countries','xf0ac','panel',4),
(10,'cms','fa fa-film fa-lg',NULL,NULL),
(11,'films','xf1c8','panel',10),
(12,'reports','fa fa-line-chart fa-lg',NULL,NULL),
(13,'salesfilmcategory','xf200','panel',12);

INSERT INTO `permissions` (`menu_id`,`groups_id`) VALUES 
(1,1), (2,1), (3,1), (4,1), (5,1), (6,1), (7,1),
(8,1), (9,1), (10,1), (11,1), (12,1), (13,1);

在本书的整个过程中,我们将创建应用程序的屏幕,并且我们需要运行一些更新查询。现在,每次我们点击任何菜单选项时,应用程序都将打开一个空面板,以便我们可以对其进行测试。

注意

注意,所有选项的className都设置为Panel。在接下来的章节中,我们将相应地更新数据库中的记录。

动态菜单 - MVC 方法

我们已经在第二章中介绍了 MVC 架构的概念,入门,但让我们再次快速概述一下。

模型代表我们想要操作的数据。它是我们数据库中表的表示。模型实例代表表的单个数据行。Store 将负责从服务器加载模型集合。Store 通常绑定到 View。View 是用户看到屏幕的组件(我们已创建了一些;例如,GridPanel 提供了 Store 中找到的数据的视觉表示)。控制器是保持一切在一起的东西。控制器将捕获来自 View 的事件,并基于它执行一些逻辑。控制器还可以将逻辑重定向到模型或 Store,使所有部件相互通信,表现得像一个调解者。

在前面的章节中,我们使用了 MVVM 架构。我们看到了仅使用 View 和 ViewController 的示例,以及使用 View、ViewModel(预定义数据)和 ViewController 的示例。我们还了解到,ViewController 绑定到其 View 的生命周期,这意味着它在 View 创建时创建,在 View 销毁时销毁。在 MVC 方法中,Controllers 只要浏览器中的应用程序在运行就保持活跃。

当在 ViewController 中时,我们可以轻松检索视图的引用或其任何子视图的引用(使用reference配置),而在 Controller 中,我们需要定义我们想要监听哪些组件的事件,并且还有另一种方法来检索组件引用。

MVC 并不比 MVVM 或反之更好。这取决于应用程序、你正在开发的屏幕类型、用例以及你的个人偏好(为什么不呢?)。这就是为什么我们将在本书的示例中使用所有可能性。这样,你可以学习所有这些(MVVM、MVC 和混合架构),并使用你最喜欢的一个!

创建模型

在使用 MVC 架构工作时,我们通常首先创建模型。

首先,我们将创建一个名为Packt.model.menu.Accordion的类。为此,我们需要创建app/model/menu目录,并创建一个名为Accordion.js的文件。有了这个模型,我们将能够表示我们想要创建的每个手风琴面板(这些面板将由 TreePanel 表示)。因此,对于每个模块(或 TreePanel),我们想要设置一个标题以及一个图标。

以下代码片段展示了Packt.model.menu.Accordion类的实现:

Ext.define('Packt.model.menu.Accordion', {
    extend: 'Ext.data.Model',

    fields: [ //#1
        { name: 'id', type: 'int'}, //#2
        { name: 'text' },
        { name: 'iconCls' }
    ]
});

模型的主配置称为 fields#1)。在这个配置中,我们可以声明模型的所有 fields(这类似于数据库表中的列)。Ext.data.field.Field 类代表每个字段。每个字段都可以有 name 属性,也可以有 type 属性。可用的类型有 stringintnumberbooleandateauto(当未定义类型时,在这种情况下,字段不会尝试将值转换为任何默认类型)。texticonCls 字段的类型为 auto

每个模型都需要一个作为其唯一标识符的字段。在这种情况下,我们将字段 id#2)定义为我们的唯一字段。如果模型的唯一标识符不是 id,我们需要使用 idProperty 配置在模型中配置此信息。例如,如果我们的 ID 被命名为 accordionId,我们通常会声明它,并在模型内部添加 idProperty: 'accordionId',这样模型就会知道该字段是标识符。

注意

ModelField 类还有其他功能。请参考 Ext JS 文档中的它们,以查看所有功能,Ext JS 文档非常完整且充满示例。请查看 docs.sencha.com/extjs/5.0/apidocs/#!/api/Ext.data.Modeldocs.sencha.com/extjs/5.0/apidocs/#!/api/Ext.data.field.Field

我们还需要一个模型来表示菜单选项,它由 TreePanel 的树节点组成。为此,我们将声明以下模型:

Ext.define('Packt.model.menu.TreeNode', {
    extend: 'Ext.data.Model',

    fields: [
        { name: 'id', type: 'int'},
        { name: 'text' },
        { name: 'iconCls' },
        { name: 'className' },
        { name: 'parent_id', mapping: 'menu_id'} //#1
    ]
});

TreeNode 模型声明了三个在 NodeInterface 类中存在的字段,它们是 idtexticonClsclassName 值将被用作 xtype 来实例化表示菜单选项屏幕的类。我们稍后会更多地讨论这个问题。然后,我们有 parent_id 字段。在 JSON 中,当我们从服务器加载数据时,我们将有 menu_id 属性而不是 parent_id。我们可以使用 mapping 配置来建立这个链接(#1)。这很好,因为我们的 Ext JS 模型不需要与从服务器来的数据完全相同(但如果它们相同,这会使我们的生活更轻松)。

与 hasMany 关联一起工作

在 Ext JS 中处理关联有两种不同的方式。第一种是使用在 Ext JS 4 中引入的 Ext.data.association.Association。第二种是使用在 Ext JS 5 中引入的 reference 配置。尽管 Ext JS 4 的这些功能在源代码中被标记为遗留代码,但 Ext JS 5 与 Ext JS 4 的模型关联保持向后兼容。

在这个例子中,我们将使用 Ext JS 4 中引入的hasMany关联。在本书的后面部分,我们将看到另一个使用reference的例子,我们将能够比较两者的区别并选择何时使用其中一个。

要使用hasMany关联,我们首先需要在Accordion模型中添加hasMany配置:

hasMany: {
    model: 'Packt.model.menu.TreeNode',
    foreignKey: 'parent_id',
    name: 'items' //#1
}

这意味着将为Accordion模型创建一个名为items (#1)的新字段。对于Accordion模型的每个实例,也将有一个名为items()的方法可用于检索TreeNode模型实例。

我们不能忘记在Accordion模型的开头添加requires声明,如下面的代码所示:

requires: [
    'Packt.model.menu.TreeNode'
],

从服务器创建存储加载菜单

现在我们已经定义了模型,我们可以继续创建存储。我们将创建一个名为Packt.store.Menu的新类,因此我们需要在app/store文件夹下创建一个名为Menu.js的新文件,其内容如下:

Ext.define('Packt.store.Menu', {
    extend: 'Ext.data.Store',

    requires: [
        'Packt.util.Util' //#1
    ],

    model: 'Packt.model.menu.Accordion', //#2

    proxy: {
        type: 'ajax',             //#3
        url: 'php/menu/list.php', //#4

        reader: { //#5
            type: 'json',
            rootProperty: 'data'
        },
        listeners: {
            exception: function(proxy, response, operation){ //#6
              Packt.util.Util.showErrorMsg(response.responseText);
            }
        }
    }
});

在存储中,我们需要声明两个重要属性:modelproxymodel告诉存储需要从服务器(或客户端存储)加载什么类型的数据,而proxy告诉存储如何以及在哪里获取数据。

这个存储将使用模型Packt.model. (#2)。除了使用model配置外,还可以直接在存储中使用fields配置声明字段(它将是从模型复制过来的fields)。在这种情况下,存储将使用所谓的“匿名模型”。

存储将通过代理使用 Ajax 请求与提供的url通信。reader (#5)告诉代理如何从服务器解码信息并将其转换为指定模型的集合。这个存储期望一个具有rootProperty(在 Ext JS 4 中称为root)名为data的 JSON 格式,如下所示:

{
  "data":[
    {
      "id":"1",
      "text":"menu1",
      "iconCls":"fa fa-group fa-lg",
      "items":[
        {
          "id":"2",
          "text":"menu11",
          "iconCls":"xf0c0",
          "className":"panel",
          "menu_id":"1",
          "leaf":true
        },
        {
          "id":"3",
          "text":"menu12",
          "iconCls":"xf007",
          "className":"panel",
          "menu_id":"1",
          "leaf":true
        }
      ]
    }
  ]
}

注意

由于我们在前面的主题中声明了关联,存储知道如何解码嵌套的 JSON 并创建相应的模型实例。

当然,也可能发生异常!因此,我们可以在proxy中添加exception listener (#6),以便我们可以向用户显示错误消息。由于我们正在使用我们创建的Util类,我们还需要在requires声明中添加这个类(#1)。

我们可以在控制器中监听视图和存储的事件;然而,存储没有任何异常事件。因此,需要直接在proxy中添加listener。这是一个可选步骤,但如果发生错误,UI 中不会发生任何操作(Ext JS 将在浏览器的控制台中输出错误消息,但用户不会使用带有开发者工具控制台打开的应用程序)。这就是为什么在每一个我们使用的代理中声明exception listener是一个好习惯。我们将开发一个更优雅的方法,但现在我们将这样使用它。

注意

我们可以在 Store 中而不是在 Model 中声明 proxy。您可以在您偏好的位置声明 proxy。然而,如果您在 Model 和 Store 上声明 proxy,当使用 Store 的 sync() 方法时,它将调用 Store 上声明的 proxy 实例;否则将使用 Model 的 proxy

现在我们知道了需要从服务器检索的数据格式,让我们来实现它!

处理服务器上的动态菜单

正如我们在 Packt.store.Menu 商店中声明的,我们需要在 php 文件夹下创建一个名为 menu/list.php 的新文件。以下是我们需要遵循的编程逻辑:

  1. 从会话中获取已登录的用户。

  2. 打开与数据库的连接。

  3. permission 表中选择菜单 id 实例,以便我们知道会话中的用户有什么权限。

  4. 选择用户有权限访问的模块——menu_idnull

  5. 对于每个模块,选择用户可以访问的节点(菜单选项)。

  6. 将结果编码为 JSON 格式,并将其包装在 data 根节点中(如商店所指定)。

让我们动手编写代码。以下代码是按函数组织的,因此我们可以按照前面指令列表中的顺序组织代码:

<?php
require('menuFunctions.php'); // #1

session_start(); // #2

$userName = $_SESSION['username']; // #3

$permissions = retrievePermissions($userName); // #4
$modules = retrieveModules($permissions);      // #5
$result = retrieveMenuOptions($modules, $permissions); // #6

echo json_encode(array( // #7
    "data" => $result
));

由于代码是按函数组织的,让我们在 php/menu 文件夹内创建另一个名为 menuFunction.php 的文件。由于我们将在该文件中编写函数,我们需要在 menu/list.php 中包含此文件(#1)。

然后,我们将获取会话中已登录的用户(#2)。因此,我们将获取已登录用户的权限,稍微提高一点安全性。

然后,我们将按照以下步骤进行。对于每个我们与数据库建立连接的功能,我们将检索用户的权限(#4),然后检索用户有权限访问的模块(#5),然后根据模块,检索用户也有权限访问的菜单选项(#6)。

最后,我们将结果数组以 JSON 格式(#7)进行编码,并将生成前述主题中列出的 JSON。

获取用户权限

现在,让我们深入了解 menuFunctions.php 文件内部声明的每个函数。第一个是用于检索用户权限的函数,如下所示:

function retrievePermissions($userName){

    require('../db/db.php'); // #8

    $sqlQuery = "SELECT p.menu_id menuId FROM User u "; // #9
    $sqlQuery .= "INNER JOIN permissions p ON u.groups_id = p.groups_id ";
    $sqlQuery .= "INNER JOIN menu m ON p.menu_id = m.id ";
    $sqlQuery .= "WHERE u.username = '$userName' ";

    $permissions = [];

    if ($resultDb = $mysqli->query($sqlQuery)) { // #10
        while($user = $resultDb->fetch_assoc()) { // #11
            $permissions[] =  $user['menuId'];
        }
    }

    $resultDb->free(); // #12
    $mysqli->close();  // #13

    return $permissions; // #14
}

首先,我们将打开数据库连接(#8),然后准备 SQL 查询以获取用户有权限访问的 menu 表中的 id 实例(#9)。我们使用 JOIN 是因为我们拥有的唯一信息是 username,我们需要通过 groups 表到达 permissions 表。

我们执行 SQL 查询(#10),并创建一个包含用户有权限使用的菜单 id 的数组(#11)。

最后,我们释放结果集(#12),关闭数据库连接(#13),并返回 permissions 数组(#14)。

获取用户有权访问的模块

现在让我们看看根据用户权限检索模块的功能:

function retrieveModules($permissions){

    require('../db/db.php');

    $inClause = '(' . join(',',$permissions) . ')'; // #15

    $sqlQuery = "SELECT id, text, iconCls FROM menu WHERE menu_id IS NULL AND id in $inClause"; // #16

    $modules = [];

    if ($resultDb = $mysqli->query($sqlQuery)) { // #17
        while($module = $resultDb->fetch_assoc()) {
            $modules[] = $module;

        }
    }

    $resultDb->free();
    $mysqli->close();

    return $modules; // #18
}

为了能够检索用户可以访问的模块,我们需要知道用户有权访问哪些模块(来自 menu 表且没有 menu_id 父菜单的记录)。在我们的 SQL 查询中(#16),有一个 IN 子句用于检索所有模块。因为我们有 permissions 数组,我们可以使用 join PHP 函数(#15),它将返回一个由我们告知的 "," 分隔符分隔的所有数组值的字符串。然后,我们将它与 "( )" 连接起来,我们就准备好了。

下一步是执行 SQL 查询(#17),然后使用数据库的结果创建一个包含用户可以访问的 modules 的数组,并在函数末尾返回这个信息(#18)。

modules 变量代表我们创建的 Accordion Model 类。

根据模块和权限检索菜单选项

在检索到用户可以访问的 permissionsmodules 之后,是时候检索创建动态菜单数据的最后一部分,即检索菜单选项。代码如下所示:

function retrieveMenuOptions($modules, $permissions){

    require('../db/db.php');

    $inClause = '(' . join(',',$permissions) . ')'; // #1

    $result = [];

    foreach ($modules as $module) { // #2

        $sqlQuery = "SELECT * FROM menu WHERE menu_id = '"; // #3
        $sqlQuery .= $module['id'] ."' AND id in $inClause";

        if ($resultDb = $mysqli->query($sqlQuery)) { // #4

            $count = $resultDb->num_rows; // #5
            if ($count > 0){ // #6
                $module['items'] = array(); // #7
                while ($item = $resultDb->fetch_assoc()) {
                    $module['items'][] = $item; // #8
                }
            }
            $result[] = $module; // #9
        }
    }
    $resultDb->close();
    $mysqli->close();

    return $result; // #10
}

对于用户可以访问的每个 module#2),我们将检索菜单选项(#3)。为此,我们需要模块的 ID 和 permissions#1)。由于每个模块可能有多个菜单选项,用户可能没有权限访问所有这些选项。

接下来,我们执行查询(#4)并检索数据库返回的记录数(#5)。如果这个数字是正数(#6),我们创建 items 数组(#7),即 hasMany 关联,并获取每个项目,将其添加到 module['items'] 数组中(#8)。

module['items'] 内的每个 $item 变量代表 TreeNode 模型。

最后,我们将 $item 添加到 result 变量中(#9)并返回它(#10)。这个 result 变量是会被包裹在 data 根节点内,并编码为 JSON 格式以返回给 Ext JS 的。

menu数据库与 Ext JS 的需求完美匹配。我们根据 Ext JS 的期望设计了menu表,并且以一种对我们来说更容易检索信息的方式。前面的服务器端代码也完美地符合 Ext JS 的需求。除非我们有在从头开始的项目中自己设计数据库的机会,否则我们可能有一个不同的设计;因此,检索信息的服务器端代码也会略有不同。完全没有问题。数据库看起来如何或你需要编写什么服务器端代码来检索信息都无关紧要。然而,Ext JS 期望以特定的格式将信息发送回前端代码,不幸的是,我们需要以这种特定格式(前面列出的 JSON)发送信息。如果我们从数据库检索到的信息不是 Ext JS 期望的格式(与前面代码相同的格式),我们只需要解析和转换它,这意味着在我们的服务器端代码在将其发送回 Ext JS 之前将有一个额外的步骤。

使用折叠布局和树面板创建菜单

我们可以回到 Ext JS 代码中,现在开始实现动态菜单视图组件。首先,我们需要在app/view/menu下创建一个新文件夹,并创建一个名为Accordion.js的新文件,如下所示:

Ext.define('Packt.view.menu.Accordion', {
    extend: 'Ext.panel.Panel',
    xtype: 'mainmenu', // #1

    width: 250, // #2
    layout: {
        type: 'accordion', // #3
        multi: true        // #4
    },
    collapsible: true, // #3
    split: true,       // #4
    iconCls: 'fa fa-sitemap fa-lg', // #5
    title: translations.menu  // #6
});

这个类将是一个面板,它将包裹作为菜单的 TreePanels。它将使用折叠布局(#3);这样,用户可以展开或折叠所需的模块。默认行为是所有模块都将展开(#4)。如果一次只应该展开一个模块,我们可以注释掉#4行。

由于我们将在Main类的west区域使用这个类,我们将声明xtype#1)。

在声明xtype时非常小心。始终记得创建一个唯一的xtype,而不是任何已经被 Ext JS 组件使用的xtype。例如,假设有一个你可能想要使用的xtype属性,但如果我们去文档中快速搜索,我们会看到这个xtype属性已经被 Ext JS 使用,如下所示:

使用折叠布局和树面板创建菜单

由于此面板将在west区域渲染,我们需要设置一个width#2)——记住,无论何时我们使用 Border 布局,都需要为westeast区域指定一个width。我们还将允许用户调整west区域的尺寸(#4)并折叠(#3),以便为中心屏幕腾出更多空间。

最后,为了美化,我们声明了一个 Font Awesome 图标(#5)和一个title属性(#6)。我们绝不能忘记在locale/en.js文件中添加以下条目及其翻译:

menu: 'menu',

注意

您可以通过从本书下载源代码包或从 GitHub 仓库 github.com/loiane/masteringextjs 下载来获取西班牙语和葡萄牙语的翻译。

接下来,我们需要创建一个 TreePanel 来表示每个模块。我们将创建一个名为 Packt.view.menu.Tree 的新类;因此,我们需要在 app/view/menu 下创建一个名为 Tree.js 的新文件:

Ext.define('Packt.view.menu.Tree', {
    extend: 'Ext.tree.Panel',
    xtype: 'menutree',

    border: 0,
    autoScroll: true,
    rootVisible: false
});

这个类是一个 TreePanel。我们不想为我们的模块设置 border 属性,并且 root 属性将不可见。正如我们所看到的,我们没有设置很多属性。我们将在控制器上动态设置缺失的信息。

最后一步是回到 Main 类,并将 Accordion 添加到 west 区域。要做到这一点,首先我们不能忘记将类 'Packt.view.menu.Accordion' 添加到 requires 中,并将 west 区域代码更改为以下内容:

{
 xtype: 'mainmenu',
    region: 'west'
}

我们移除了所有配置(widthsplit)并将 xtype 替换为 menu.Accordion。当我们执行应用程序时,我们将能够看到现在有一个面板的 west 区域。动态菜单的创建仍在等待中,我们将在下一步处理它。

创建菜单控制器

我们已经拥有了所有的视图、模型和存储,并且也覆盖了服务器端。现在唯一剩下的事情就是实现控制器,这是所有魔法发生的地方。所以,让我们继续创建一个名为 Menu.js 的新文件,位于 app/controller 文件夹下:

Ext.define('Packt.controller.Menu', {
    extend: 'Ext.app.Controller',

    init: function(application) {

     this.control({ // #1
            "menutree": { // #2
                itemclick: this.onTreePanelItemClick // #3
            },
            "mainmenu": {
                render: this.renderDynamicMenu // #4
            }
        });
    }
});

注意

在 MVC 方法中,所有控制器都创建在 app/controller 文件夹内,而在 MVVM 方法中,ViewController 创建在视图相同的文件夹中。

当应用程序启动时,会调用 init 函数,并且它会在应用程序的 launch 函数之前被调用。我们可以使用这个函数在第一个视图(Login 或视口)创建之前执行任何逻辑。在这个函数内部也是我们想要设置控制器要监听的事件的地方,使用 control 函数(#1)。

在这个控制器中,我们想要监听两个事件。第一个事件是当视口(Main 类)被渲染后,我们想要渲染动态菜单(#4)。第二个事件是无论用户点击菜单选项(来自 TreePanel 的节点——#2),我们想要在中心面板(#3)内打开屏幕。

要在 control 方法中监听一个事件,我们需要定义三件事:选择器、控制器要监听的事件以及要执行的方法。它具有以下格式:

"{selector}": {
    event1: this.methodToBeExecuted1,
    event2: this.methodToBeExecuted2
}

找到选择器是最具挑战性的部分。在这个例子中,范围不是视图(ViewController 中的 View),而是应用程序。对于选择器,我们通常使用xtype组件。当它是一个为应用程序创建的xtype组件,而不是 Ext JS 的xtype(例如'tree')时,它很有帮助;例如,'menutree'更好,因为我们缩小了可能性(应用程序中可能有多个 TreePanels,但我们知道只有动态菜单中的 TreePanels 的xtypemenutree)。我们必须记住,如果我们使用选择器'tree'(TreePanel 的xtype),控制器将监听应用程序中所有 TreePanels 的事件,所以最好尽可能具体。

我们可以监听来自选择器的任意多个事件。我们只需要用“,”分隔事件即可。

例如,让我们首先实现renderDynamicMenu方法。我们使用mainmenu作为选择器。这是view.menu.Accordion类的xtype组件,它正在Main类的west区域(Viewport)中被渲染。所以,当这个组件被渲染时,控制器将执行renderDynamicMenu方法。让我们在Menu控制器中声明这个方法:

renderDynamicMenu: function(view, options) {
  console.log('menu rendered');
}

我们可以通过在控制器中添加console.logalert消息来始终从控制器开始实现事件监听器的实现,以确保该方法正在执行。

小贴士

记住,始终去查阅文档以验证传递给事件的参数,这样我们才能声明它们。对于render事件,通常是组件本身和options

让我们声明onTreePanelItemClick,这样我们就可以测试这个控制器:

onTreePanelItemClick: function(view, record, item, index, event, options){ },

方法签名已经足够,我们不会得到任何错误。

我们还需要在Application.js中添加这个控制器。所以,回到Application.js并添加以下突出显示的代码:

controllers: [
    'Root',
 'Menu'
],

如果我们运行应用程序,我们将得到以下截图所示的输出:

创建菜单控制器

这意味着它正在按预期工作。不要担心其他警告信息;我们将在本书的后面部分处理它们(重要的是不要出现错误信息)。

因此,让我们继续添加所需的业务逻辑。

从嵌套 JSON(hasMany 关联)渲染菜单

我们需要添加负责创建包含从服务器接收的信息的动态菜单的逻辑。我们将在renderDynamicMenu方法中添加以下代码:

var dynamicMenus = [];  //#1

view.body.mask('Loading Menus... Please wait...');  //#2

this.getMenuStore().load(function(records, op, success){ //#3

    Ext.each(records, function(root){ //#4

        var menu = Ext.create('Packt.view.menu.Tree',{ //#5
            title: translations[root.get('text')],     //#6
            iconCls: root.get('iconCls')               //#7
        });

        var treeNodeStore = root.items(),     //#8
            nodes = [],
   item;

        for (var i=0; i<treeNodeStore.getCount(); i++){ //#9
            item = treeNodeStore.getAt(i);              //#10

            nodes.push({                      //#11
                text: translations[item.get('text')],
                leaf: true,                   //#12
                glyph: item.get('iconCls'),   //#13
                id: item.get('id'),
                className: item.get('className')
            });
        }
       menu.getRootNode().appendChild(nodes); //#14
        dynamicMenus.push(menu); //#15
    });
    view.add(dynamicMenus); //#16
    view.body.unmask();     //#17
});

我们将要做的第一件事是创建一个空数组,以便我们可以将所有模块添加到其中(#1)。由于我们将向服务器发出请求,并且最初 west 区域中的菜单将是空的,因此添加一个加载消息(#2)以备不时之需是个不错的选择,以防服务器响应有任何延迟(Ajax 是异步的)。view 作为参数传递给事件,是对 Packt.view.menu.Accordion 类实例的引用。

然后,我们需要加载 Menu Store(#3),它负责从服务器加载嵌套的 JSON(#1)。注意,我们使用 this.getMenuStore() 来检索 Store,这意味着这是一个来自 Controller 的方法。我们需要在 Controller 的 stores 配置中声明 menu Store,这样它就会为我们生成这个方法:

stores: [
    'Menu'
],

在这种情况下,没有必要声明 Store 的完整名称——只需 'Packt.store' 后面的部分即可。由于采用了 MVC 架构,Ext JS 会知道它需要在 app/store 文件夹内查找名为 Menu.js 的文件。由于 Store 的名称是 Menu,一旦 Store 被加载,Controller 将会生成一个名为 get + Menu + Store(即 getMenuStore)的方法(这就是为什么我们要在加载回调中处理动态菜单的创建)。对于 Store 返回的每个 record(即 #4),我们将创建一个 TreePanel(Packt.view.menu.Tree——#5)来表示每个模块。我们将设置 title 属性(从本地化文件中获取 title 属性——#6)。在数据库中,我们将 en.js 文件中的键存储在 text 列中,并设置 iconCls(#7)以使其看起来更美观(一个 Font Awesome 图标)。

注意

Ext.create 或 Ext.widget

在 第二章 中,入门,当我们在这本书中第一次实例化一个类时,我们讨论了在需要实例化一个 component 类时我们可以使用的选项。只是为了提醒你,我们可以使用 Ext.create 并传递类的完整名称作为参数,或者我们可以使用 Ext.widget 并传递类别名作为参数(还有其他方法,但这两个更常用)。这是一个个人喜好问题,但你可以使用我们在 第二章 中提到的任何方法,入门

因此,使用这段代码,我们能够显示代表每个模块的每个 TreePanel(没有菜单选项)。下一步是获取要在 TreePanel 中显示的数据。这些数据已经通过我们配置的 hasMany 关联在 JSON 中可用。由于 hasMany 关联,Store 中的每个 Model 实例内部将创建一个名为 items(即我们配置的关联的 name)的方法。items 方法返回一个包含 hasMany 数据的 Store(#8)。

当我们不确定方法是否被创建时,我们总是可以在控制台中检查 Model 实例。例如,如果我们想在load回调中输出console.log(this),我们将在浏览器控制台中看到 Store 的输出。检查data配置,在data配置中,我们将找到items,它包含如以下截图所示的 Model 集合:

从嵌套 JSON 渲染菜单(hasMany 关联)

对于从 Store(#9#10)中获取的每个Packt.model.menu.TreeNode Model 实例,我们将一个新节点(#11)推入一个数组。在添加新节点时,我们设置来自NodeInterface类的textleaf#12)属性。我们添加了不是NodeInterface类一部分的idclassName

为了添加所有创建的节点,我们需要访问 TreePanel 的Root节点。由于Root也是一个节点(装饰了NodeInterface类),它有一个名为appendChild的方法,可以用来将节点追加到特定的节点(在这种情况下,Root)。因此,我们一次性添加我们创建的所有节点(#14)。

为了避免在 DOM 中进行许多更改,这不是一个好的做法,我们将创建一个包含我们创建的所有菜单的数组(#15)。然后,我们将一次性将所有菜单添加到Accordion面板中(这将避免浏览器重排)。

注意

最小化浏览器重排是一种提高性能的技术。我们可以在循环内部进行插入操作,但这会增加 DOM 操作,而这是非常昂贵的。通过在代码中进行简单的更改,一次性添加所有需要添加的内容,我们可以略微提高性能。更多信息,请访问developers.google.com/speed/articles/reflow

最后,我们从Accordion菜单中移除mask#17)。

如果我们执行代码,菜单将被渲染。

在 TreePanel 中使用 glyphs – 使用覆盖

在上一个主题中没有涵盖代码的一部分,即行#13中的节点glyph配置。我们知道我们可以使用像 Font Awesome 这样的图标字体与 Ext JS 一起使用,但支持仍然有限。在 TreePanel 节点中无法使用 Font Awesome 图标。然而,有一个覆盖可以让我们做到这一点。

这本书中第一次提到glyph这个术语。它基本上与我们至今使用的字体图标相同,但它有一个替代配置。例如,我们在Login类中使用了键图标,并将iconCls设置为如下:

iconCls: 'fa fa-key fa-lg'

我们可以使用glyph配置作为替代方案:

glyph:'xf084@FontAwesome'

上述代码也可以写成如下形式:

glyph:'xf084'

但我们需要在Application.js中的Packt.Application类内部配置glyphFontFamily配置,如下所示:

glyphFontFamily: 'FontAwesome'

然后,我们可以从 glyph 配置中移除 '@FontAwesome'

注意

Font Awesome 提供了一个包含 CSS 名称和符号代码的速查表,可以在fortawesome.github.io/Font-Awesome/cheatsheet/找到。

Font Awesome 的 iconCls 不会在树节点上工作,但如前所述,我们可以应用覆盖。覆盖是一种改变 Ext JS 类行为的方式。这类似于修改 JavaScript 对象的原型——新的行为将应用于该类的所有实例。

尽管 Ext JS 是开源的,但我们应避免直接更改源代码(尽管开源代码哲学)。更改源代码可能在将来升级框架版本时成为一个问题。使用覆盖更为优雅。

注意

我们将要使用的覆盖可以在www.sencha.com/forum/showthread.php?281383-Glyph-support-on-treepanels找到。

我们将在文件 app/overrides/tree/ColumnOverride.js 内创建覆盖。我们不要忘记将覆盖的名称更改为 Packt.overrides.tree.ColumnOverride(代码的第一行)。

然后,在Application.js内部,我们将添加以下代码以确保在应用程序加载和覆盖应用时加载,如下所示:

requires: [
    'Packt.overrides.tree.ColumnOverride'
],

我们还可以在 Packt.view.menu.Tree 类内部添加此 require 声明,以提醒我们我们也在使用覆盖,但这不是必需的。

这是当前 menu 表的样子。模块有 Font Awesome CSS,菜单选项有符号代码(iconCls 列),如下所示:

在 TreePanel 中使用符号 – 使用覆盖

在整个应用程序中,您只能使用符号代码。我们同时使用它们来展示这是可能的。从现在开始,您可以使用您喜欢的任何方法。

菜单区域支持

我们还需要将键添加到 en.js 文件(以及其他区域文件)中。对于菜单,这些是需要条目:

menu1 : 'Security',
menu11 : 'Groups and Permissions',
menu12 : 'Users',
staticData: 'Static Data',
actors: 'Actors',
categories: 'Categories',
languages: 'Languages',
cities: 'Cities',
countries: 'Countries',
cms: 'Content Management',
films: 'Films',
reports: 'Reports',
salesfilmcategory: 'Sales by Film Category'

注意

注意键与 menu 表中的 text 列的条目相同。

以编程方式打开菜单项

菜单渲染后,用户将能够从中选择一个选项。该方法背后的逻辑是:当用户从菜单中选择一个选项时,我们需要验证标签页是否已经在标签面板上创建。如果是,我们不需要再次创建它;我们只需要在标签面板上选择标签并使其活动。如果不是,那么我们需要实例化用户选择的屏幕。为此,控制器将执行以下方法:

onTreePanelItemClick: function(view, record, item, index, event, options){
    var mainPanel = this.getMainPanel(); // #1

    var newTab = mainPanel.items.findBy( // #2
        function (tab){ 
           return tab.title === record.get('text'); // #3
    });

    if (!newTab){ // #4
        newTab = mainPanel.add({            // #5
            xtype: record.get('className'), // #6
            closable: true,                 // #7
            glyph: record.get('glyph'),     // #8
            title: record.get('text')       // #9
        });
    }
    mainPanel.setActiveTab(newTab); // #10
}

首先,我们需要获取标签面板的引用(#1)。我们使用 this.getMainPanel(),这是由控制器创建的。

在 Controller 方法中获取对象引用有三种方式。第一种是使用传递给方法的方法参数。第二种是使用ComponentQuery(我们尚未讨论),第三种是使用refs

对于这个例子,我们将使用refs。我们需要在 Controller 中添加以下代码:

refs: [
    {
        ref: 'mainPanel',
        selector: 'mainpanel'
    }
],

我们可以配置 Controller 要搜索的选择器和引用的名称。在这种情况下,Controller 将创建一个名为get + mainPanel(在ref名称中,第一个字母变为大写)的方法,结果为getMainPanel。这相当于 ViewController 使用的reference

然后,我们需要验证所选菜单选项是否已经创建(#2),我们将通过比较标签title与所选节点的text配置来完成此操作(#3)。

如果不是新标签页,我们将将其添加到标签面板中,并将实例传递给add方法(#5)。因此,我们将从节点的className获取我们打算添加的组件的xtype配置(#6),标签可以关闭#7);它将具有与节点相同的glyph#8)并且也将具有与节点相同的title#9—菜单选项)。

然后,我们将将其设置为活动标签。如果屏幕已经渲染,我们只会将active标签更改为用户从菜单中选择的屏幕(#10)。

动态菜单功能现在已完成!

摘要

在本章中,我们学习了如何使用折叠布局和为应用程序的每个模块使用 TreePanels 实现一个高级动态菜单。我们学习了如何在服务器端处理动态逻辑,以及如何在 Ext JS 端处理其返回值,通过加载 Store 动态构建菜单。最后,我们还学习了如何通过编程方式打开菜单项并在应用程序的中心组件上显示它。我们还首次使用了 MVC 架构。

在下一章中,我们将学习如何实现列出、创建和更新用户以及如何将组分配给用户的屏幕。

第六章:用户管理

在前面的章节中,我们开发了提供登录和注销功能以及客户端会话监控的机制,我们还实现了一个基于用户权限的动态菜单。然而,直到现在,所有用户、组和权限都是手动添加到数据库中的。我们不能每次需要向新用户授予应用程序访问权限或更改用户权限时都这样做。因此,我们将实现一个屏幕,我们可以在这里创建新用户并授予或更改权限。所以在本章中,我们将涵盖:

  • 列出系统中的所有用户

  • 创建、编辑和删除用户

  • 文件上传的图片预览(用户图片)

管理用户

因此,我们将要开发的第一个模块是用户管理。在这个模块中,我们将能够看到系统中注册的所有用户,添加新用户,编辑和删除当前用户。

当用户点击 用户 菜单选项时,将打开一个新标签页,显示系统中的所有用户列表,如下截图所示:

管理用户

当用户点击 添加编辑 按钮时,系统将显示一个窗口,以便用户可以创建新用户或编辑当前用户(基于在 GridPanel 上选择的记录)。编辑 窗口将如下截图所示:

管理用户

创建或编辑用户的某些功能:我们可以编辑 用户信息,如 姓名用户名 等,我们还可以上传代表用户的 照片。但有一个额外功能;使用 HTML5 API,我们将在用户从计算机中选择图片并上传到服务器之前立即显示 照片 的预览。

使用简单的 GridPanel 列出所有用户

我们需要实现一个类似于本章第一张截图的屏幕。它是一个简单的 GridPanel。因此,要实现一个简单的 GridPanel,我们需要以下内容:

  • 一个 Model 来表示存储在 user 表上的信息

  • 用于加载数据的 Store 和用于告诉 Ext JS 从服务器读取信息的 Proxy

  • 代表视图的 GridPanel 组件

  • 用于监听事件的 ViewController,因为我们打算使用 MVVM 模式来开发这个模块

创建用户 Model

因此,第一步是创建一个 Model 来表示 user 表。我们将在 app/model/security 目录下创建一个名为 User.js 的新文件。这个 Model 将表示 user 表中的所有字段,除了 password 字段,因为密码是用户非常私人的信息,我们不能向任何其他用户显示用户的密码,包括管理员。因此,用户 Model 将如下所示:

Ext.define('Packt.model.security.User', {
    extend: 'Packt.model.security.Base', //#1

 fields: [
        { name: 'name' },
        { name: 'userName' },
        { name: 'email' },
        { name: 'picture' },
        { name: 'groups_id' , type: 'int'}
});

正如我们之前提到的,user 表中的所有字段都映射到这个 Model 中,除了 password 字段。

在行 #1 中,我们没有扩展默认的 Ext.data.Model 类。我们扩展了一个我们创建的类。让我们看看它的声明。

与模式一起工作

当我们设计数据库表时,我们还向 user 表添加了一个外键。这意味着 user 表与 groups 表有关联。Ext JS 5 引入了模式的概念。模式(Ext.data.schema.Schema)是一组相关实体及其相应的关联。我们知道 UserGroup 模型类是相关实体,因此我们可以创建一个模式来表示它们。

让我们看看 Packt.model.security.Base 类内部的内容:

Ext.define('Packt.model.security.Base', {
    extend: 'Ext.data.Model',

    requires: [
        'Packt.util.Util'
    ],

    idProperty: 'id',

    fields: [
        { name: 'id', type: 'int' } //#1
    ],

    schema: {
        namespace: 'Packt.model.security', //#2
        urlPrefix: 'php',                  //#3
        proxy: {
            //proxy code here
        }
    }
});

security.Base 模型将作为一个超级模型工作,该模型包含 UserGroup 类(这些类将在本章的 声明用户视图模型 部分中定义),并为这两个模型提供共同的代码。

UserGroup 模型类共同拥有的第一个东西是 id 字段 (#1)。因此,为了在两个类中重用这个字段,我们可以在这里声明它。

接下来,configschema。在 schema 中,我们可以配置一些选项。第一个是 namespace (#2)。在某些情况下,我们希望为模型实体使用简短的名字。当我们声明关联和稍后在 ViewController 中时,我们将使用 UserGroup 类的简短名字。这个简短的名字在 Ext JS 中也被称为模型的 entityName(我们也可以在模型中声明这个 config)。默认情况下,entityName 是完整的类名,但这正是我们试图避免的。然而,如果使用了 namespace(在 schema 声明中),则可以丢弃公共部分,并可以派生出更短的名字。例如,User 类的完整名字是 Packt.model.security.User,其模式命名空间是 Packt.model.security,因此 entityName 将结果是 User。仅使用 User 而不是 Packt.model.security.User 会更好。

我们还有一个 urlPrefix (#3), 这是用于所有服务器请求的 URL 前缀。当配置 proxy 时(在 第五章,高级动态菜单 中,我们在 Store 中使用了 proxy;现在我们将在 Model 中使用它)我们将使用这个信息。

接下来,我们将按照以下方式配置 proxy。由于我们在 schema 声明中使用 proxy,因此该配置将对所有扩展 Packt.model.security.Base 类的类可用:

type: 'ajax',
api :{
    read : '{prefix}/{entityName:lowercase}/list.php', //#4
    create: '{prefix}/{entityName:lowercase}/create.php',
    update: '{prefix}/{entityName:lowercase}/update.php',
    destroy: '{prefix}/{entityName:lowercase}/destroy.php'
},
reader: {
    type: 'json',
    rootProperty: 'data'
},
writer: { //#5
    type: 'json',
    writeAllFields: true,
    encode: true,
    rootProperty: 'data',
    allowSingle: false
},
listeners: { //#6
    exception: function(proxy, response, operation){
        Packt.util.Util.showErrorMsg(response.responseText);
    }
}

当我们想要为每个 CRUD 操作指定不同的url时,而不是使用url配置,我们使用api配置。在api配置内部,我们为每个 CRUD 操作定义一个url配置。当使用模式时,我们可以在proxy中使用一个模板来配置 URL。例如,我们使用prefix,它指的是我们之前配置的urlPrefixentityName属性指的是模型的entityName(在这个例子中,我们还要求将entityName转换为小写)。在行#4中,对于User模型类,读取url将是php/user/list.php。当我们想要遵循一个模式并在不同的模型之间共享(重用)schema配置时,这非常有用。

我们已经学习了如何配置reader。当我们想要向服务器发送信息(创建、更新或删除记录)时,我们也可以指定writer#5)。在这种情况下,我们正在告诉 Ext JS 我们想要将一个 JSON 发送回服务器。writeAllFields配置指定了我们是否希望将模型(及其所有字段)发送到服务器,或者只发送已修改的字段(加上id字段)。为了使服务器端代码更简单,我们将writeAllFields设置为 true。就像reader一样,我们还将配置rootProperty以作为记录的包装器。然后,我们将encode配置设置为true,以便将记录数据(如果writeAllFieldstrue,则为所有记录字段)作为由rootProperty配置命名的 JSON 编码的 HTTP 参数发送。当rootProperty被定义时,编码选项才应设置为true,因为值将作为请求参数的一部分发送,而不是原始的 POST。最后,我们将allowSingle设置为false。这将强制proxy获取所有已修改的记录(要创建、更新或删除的记录)并将它们作为一个数组(如果已配置,则由rootProperty包装)发送。这将使proxy只向服务器发送一个请求(一个用于创建、更新或删除记录的请求),而不是每个修改发送一个请求。

最后,我们有代理exception listener#6),这是我们已经在之前的章节中熟悉的。

使用 Users GridPanel 定义无存储网格

下一步是创建我们将用于管理应用程序用户的视图。但在我们动手编写代码之前,我们需要记住一件事;当我们实现 Manage Groups 模块并在 Edit Group 屏幕上时,我们希望显示属于该组的所有用户。为此,我们将需要使用一个 Users 网格。所以,我们需要创建一个可以稍后重用的组件来列出用户(在这种情况下是应用程序中的所有用户)。因此,我们将创建的组件将只包含用户列表,而不会包含 Add/Edit/Delete 按钮。我们将添加一个带有这些按钮的工具栏,并将 Users 网格包裹在另一个组件中。

因此,我们将创建一个 GridPanel。为了做到这一点,让我们在 app/view/security 目录下创建一个名为 Packt.view.security.UsersGrid 的新类。要创建这个类,我们将创建一个名为 UsersGrid.js 的新文件:

Ext.define('Packt.view.security.UsersGrid', {
    extend: 'Ext.grid.Panel',
    alias: 'widget.users-grid',  //#1

    reference: 'usersGrid', //#2

    columns: [  //#3
        {
            width: 150,
            dataIndex: 'userName',  //#4
            text: 'Username'
        },
        {
            width: 200,
            dataIndex: 'name',
            flex: 1,             //#5
            text: 'Name'
        },
        {
            width: 250,
            dataIndex: 'email',
            text: 'Email'
        },
        {
            width: 150,
            dataIndex: 'groups_id', //#6
            text: 'Group'
        }
    ]
});

如同往常,我们将从 xtype 开始。xtype 的一个替代方案是使用 alias (#1)。当使用 xtype 时,我们可以直接声明它(例如 xtype: 'user-grid')。当使用 alias 时,我们需要指定我们正在创建的别名类型。对于组件,我们使用 "widget." 而对于插件,我们使用 "plugin.",然后跟随着 xtype

让我们继续创建 reference,这样我们就可以在 ViewModel (#2) 中稍后引用这个组件。

每当我们声明一个网格时,我们需要指定两个强制性的配置。第一个是 columns (#3) 配置,第二个是 store 配置。

columns (#3) 配置是一个列定义对象的数组,它定义了网格中出现的所有列。每个列定义提供了列的标题 text (text 配置),以及该列数据来源的定义(dataIndex #4)。

由于网格将显示由模型表示的数据集合,每个列都需要配置 dataIndex (#4) 以匹配它所代表的模型字段。

我们可以为每个列定义一个 width。但我们不知道用户将使用的显示器分辨率,我们可能会剩下一些额外空间。我们可以选择一个列来使用所有可用空间,通过指定 flex 配置(#5)。

最后,在第 #6 行,我们有一个 dataIndexgroups_id 的列,它将渲染来自 groups 表的外键 groups_id。当我们在一个网格中显示关联数据时,我们不想显示外键,而是显示描述或信息名称。现在,我们将保持 groups_id 配置,但我们会回来并更改这一点。

在声明网格时,也需要store配置。但这个配置在这个类中缺失。Ext JS 5 引入了 ViewModel,由于这个新的架构和数据绑定概念,我们可以声明无存储的网格,并稍后进行配置。

用户屏幕

现在我们已经有了用户GridPanel,我们仍然需要创建另一个组件,它将包装用户GridPanel,并且还将包含带有添加/编辑/删除按钮的工具栏。支持 Docked Items 的最简单组件是面板。

我们将创建一个名为Packt.view.security.User的新类,它将扩展Ext.panel.Panel类。为此,我们需要在app/view/security目录下创建一个名为User.js的新文件,如下所示:

Ext.define('Packt.view.security.User', {
    extend: 'Ext.panel.Panel',
    xtype: 'user',

    requires: [
        'Packt.view.security.UsersGrid' //#1
    ],

    controller: 'user', //#2
    viewModel: {        //#3
        type: 'user'
    },

    frame: true,        //#4

    layout: {           //#5
        type: 'vbox',
        align: 'stretch'
    },

    items: [
        {
            xtype: 'users-grid', //#6
            flex: 1              //#7
        }
    ]
});

在这个类中,我们现在将在面板的主体中渲染一个组件。它是users-grid#6)。由于我们使用其xtype来实例化它,我们需要确保UsersGrid类已经加载,这就是为什么我们需要在requires声明中添加类的理由(#1)。

之后,我们将创建一个带有表单的窗口(弹出窗口),允许我们创建或编辑用户。由于一些 ViewModel 概念和限制,我们将添加窗口为此类的项。因此,我们不会使用 Fit Layout(渲染单个子项),而是将使用VBox布局(#5)。

VBox布局垂直对齐子项。它使用flex配置(#7)在子项之间划分可用的垂直空间。在这个例子中,窗口将显示为弹出窗口,因此网格将继续作为唯一的子组件。

当使用VBox布局时,我们还可以定义项目的对齐方式。我们将使用align: 'stretch'。根据 Ext JS 文档,可能的选项如下:

  • begin:子项在容器的顶部垂直对齐

  • middle:子项在容器中垂直居中

  • end:子项在容器的底部垂直对齐

  • stretch:子项垂直拉伸以填充容器的宽度

  • stretchmax:子项垂直拉伸到最大项的高度

要在屏幕周围添加边框,我们将设置frame:true#4)。我们还指定了controller#2)和ViewModel#3)来为我们将在此章后面创建的View

小贴士

除了可以为任何面板子类设置的border配置之外,还有border配置,当指定为false(默认值)时,将渲染具有零宽度边框的面板。当frame配置指定为true时,将面板应用框架。

与 Docked Items 一起工作

下一步是添加带有 添加编辑删除 按钮的工具栏,因此我们将把这个工具栏 停靠顶部,并在 Packt.view.security.User 类的 dockedItems 声明中声明它:

dockedItems: [
    {
        xtype: 'toolbar',
        dock: 'top', //#1
        items: [
            {
                xtype: 'button',
                text: 'Add',
                glyph: Packt.util.Glyphs.getIcon('add'), //#2
                listeners: {
                    click: 'onAdd' //#3
                }
            },
            {
                xtype: 'button',
                text: 'Edit',
                glyph: Packt.util.Glyphs.getIcon('edit'),
                listeners: {
                    click: 'onEdit'
                }
            },
            {
                xtype: 'button',
                text: 'Delete',
                glyph: Packt.util.Glyphs.getIcon('destroy'),
                listeners: {
                    click: 'onDelete'
                }
            }
        ]
    }
]

dockedItems 配置中,我们可以添加一个组件或组件集合,将其作为停靠项添加到面板或其任何子类。停靠项可以停靠到面板的 顶部右侧左侧底部。我们可以根据需要添加任意多个,通常用于在面板(或其任何子类)内声明工具栏。

在这个例子中,我们在面板的 顶部 (#1) 添加了一个工具栏。工具栏有三个按钮。对于每个按钮,我们将使用 glyph (#2) 配置一个图标,并配置我们将在 ViewController 中创建的事件监听器 (#3)。

如果我们再次查看第 #2 行,我们可以看到我们还没有实现 Packt.util.Glyphs 类。在我们深入研究 ViewModel 和 ViewController 代码之前,让我们先着手处理它。

与单例一起工作——Ext JS 类系统

让我们享受为我们的项目创建一个新的实用类的机会,并深入了解 Ext JS 的类系统概念。

我们已经知道我们可以在按钮的 iconCls 配置(或任何支持它的其他组件)中使用 Font Awesome CSS,我们也了解到我们可以使用 glyph 配置作为替代。使用 glyph 的缺点是将代码作为值声明(xf067),如果我们决定将来读取此代码或另一个开发者决定维护它,这并不很有帮助;毕竟,'xf067' 代表什么?

我们可以利用 Ext JS 的类系统——特别是单例类——来创建一个将为我们处理这些任务的实用类。让我们看看 Packt.util.Glyphs 类的代码:

Ext.define('Packt.util.Glyphs', {
    singleton: true, //#1

    config: { //#2
        webFont: 'FontAwesome',
        add: 'xf067',
        edit: 'xf040',
        destroy: 'xf1f8',
        save: 'xf00c',
        cancel: 'xf0e2'
    },

    constructor: function(config) { //#3
        this.initConfig(config);
    },

    getGlyph : function(glyph) { //#4
        var me = this,
            font = me.getWebFont(); //#5
        if (typeof me.config[glyph] === 'undefined') {
            return false;
        }
        return me.config[glyph] + '@' + font;
    }
});

策略是在类的配置(#2)中声明 glyph 代码,并使用它们作为键来检索 glyph 代码。Packt.util.Glyphs.getIcon('add')'xf067' 更容易理解。我们可以在整个应用程序中重用它,如果我们想更改 添加 按钮的代码,我们可以更改 Glyphs 类,整个应用程序的代码都会更改。

让我们理解之前的代码。我们开始声明一个类,但在第 #1 行,我们有 singleton:true。这意味着这个类将以单例的形式实例化,这意味着只能创建这个类的一个实例。

注意

要了解更多关于单例的信息,请访问 en.wikipedia.org/wiki/Singleton_pattern

接下来,我们有类的 config(#2)。在 config 中,我们可以声明类的属性。对于每个属性,Ext JS 将生成一个 getter 方法和一个 setter 方法。例如,webFont 属性可以通过 this.getWebFont() 获取,如第 #5 行所示。

方法getGlyph#4)将负责返回一个包含glyph代码 + '@' + 字体名称的字符串。如果设置了glyphFontFamily,我们就不需要指定字体。

在行#3中,我们有constructor。类构造函数是在创建该类的新实例时立即调用的类方法。在构造函数内部,我们调用initConfig方法。在构造函数中调用initConfig初始化类的配置。

如果我们需要使用不同的字体图标,这个类可以被修改。

我们不能忘记在将要使用此类的类中添加requires

requires: [
    //other requires
    'Packt.util.Glyphs'
],

面板与容器与组件

在我们继续之前,让我们回顾一下我们已经学到的内容。我们创建了一些视图。在一些视图中我们使用了组件类,在另一些视图中我们使用了容器,在其他视图中,我们使用了面板。你能说出它们之间的区别吗?什么时候使用组件、容器或面板?

组件是所有 Ext JS 组件(小部件)的基类。它具有内置的基本隐藏/显示、启用/禁用和大小控制行为支持。从视觉上讲,没有样式。我们可以设置 HTML 内容并使用一个或多个'cls'配置来设置样式。

容器是能够包含其他组件(items配置)的基类。它也是使用我们在本书中介绍布局的基类(边框、fit、VBox、anchor、accordion 等)。

面板类是一个具有更多功能的容器。面板有一个标题,我们可以设置标题并添加工具(如折叠和展开等有用的按钮)到其中,它还支持停靠项(工具栏)。

因此,每当你想要创建一个新的 Ext JS 小部件时,你需要问自己,“我需要在这个小部件中有什么?”。如果是 HTML 内容,我们使用一个组件。如果我们需要项目或需要一个容器来组织子项的布局,我们可以使用一个容器。如果我们需要设置title或在其内部有工具栏,那么我们使用面板。因为面板类具有更多功能,它也是一个更重的组件。

提示

使用正确的组件也可以帮助提高应用程序的性能。

在这个例子中,我们可以将工具栏移动到UserGrid类内部。为了组织布局,我们可以将User类转换为一个容器。如果我们只想显示UserGrid类,我们根本不需要User类。这可以避免一种称为过度嵌套的坏习惯。过度嵌套是指使用一个额外的容器,它除了包含另一个组件之外不做任何事情。

声明用户视图模型

由于我们使用 MVVM 架构,我们声明模型,然后声明视图。下一步将是声明视图模型。为此,我们将创建类Packt.view.security.UserModel,它是Packt.view.security.User类的视图模型。

注意

注意我们使用的命名约定。视图的名称是 User,因此 ViewModel 的名称将是视图名称 + 'Model'。

让我们看看 ViewModel 类:

Ext.define('Packt.view.security.UserModel', {
    extend: 'Ext.app.ViewModel',

    alias: 'viewmodel.user',

    stores: { //#1
        users: { //#2
            model: 'Packt.model.security.User',
            autoLoad: true //#3
        }
    }
});

你已经了解到,我们可以在 第三章 的 “登录页面” 中,将预定义的数据设置在 ViewModel 类中。现在我们正在配置 ViewModel 以从我们同时声明和创建的 store (#1) 加载数据。users Store(这将作为 Store ID)是用户模型集合,我们还要求 Store 自动加载(#3)(我们不需要手动调用 load 方法)。

注意

由于 ViewModel 将在创建视图时创建,因此 Store 也将与视图一起加载。这与将 Store 声明为具有 autoLoad true 的独立 Store 的方法不同——在这种情况下,Store 将在应用程序加载时创建,并将从服务器检索信息。

我们可以在 store 包内创建表示 Store 的文件,并在其中创建引用,而不是在 ViewModel 内创建 Store。这个 Store 也没有代理,因为我们是在模型内部(特别是 schema 内部,为了重用目的)声明的。

使用 ViewModel 数据绑定进行工作

让我们回到 UsersGrid 类。我们还没有声明一个 Store,这是必需的。我们将使用数据绑定并引用 ViewModel 中创建的用户 Store。

UsersGrid 类内部,我们将添加以下代码:

bind : '{users}',

这意味着 UsersGrid 将绑定到用户 Store。由于 UsersGridUser 类的子组件,它引用了用户 ViewModel,因此 UsersGrid 类也将能够访问 ViewModel。

接下来,我们将回到 User 视图类以添加另一个数据绑定。我们将向 编辑删除 按钮添加以下代码:

bind: {
    disabled: '{!usersGrid.selection}'
}

我们希望 编辑删除 按钮仅在用户从网格中选择一行时启用。如果没有选择行,点击 编辑删除 按钮就没有意义。因此,我们将根据这个约束启用或禁用按钮。它与 usersGrid (UsersGrid 类的引用) 和网格的属性选择绑定。

我们也不能忘记将 ViewModel 添加到 User 类的 requires 中:

requires: [
    //other requires
    'Packt.view.security.UserModel'd
],

我们到目前为止的代码已经完成了。现在是时候监听一些事件了!

创建用户 ViewController

下一步是创建 User 类的 ViewController,因此我们将创建 Packt.view.security.UserController 类。

注意

注意我们使用的命名约定。视图的名称是 User,因此 ViewController 的名称将是视图名称 + 'Controller'。

让我们将以下代码添加到 ViewController 类中。它包含了我们将要创建的所有事件和内部方法的签名:

Ext.define('Packt.view.security.UserController', {
    extend: 'Ext.app.ViewController',

    alias: 'controller.user',

    requires: [
        'Packt.util.Util'
    ],

    onAdd: function(button, e, options){},

    onEdit: function(button, e, options){},

    createDialog: function(record){},

    getRecordsSelected: function(){},

    onDelete: function(button, e, options){},

    onSave: function(button, e, options){},

    onSaveSuccess: function(form, action) {},

    onSaveFailure: function(form, action) {},

    onCancel: function(button, e, options){},

    refresh: function(button, e, options){},

    onFileFieldChange: function(fileField, value, options) {}
});

在我们深入研究每个方法之前,回到User视图,并将 ViewController 添加到requires声明中,以便我们可以运行和测试到目前为止所编写的代码:

requires: [
    //other requires
    'Packt.view.security.UserController'
],

为了能够执行代码,我们还需要在数据库上执行UPDATE操作:

UPDATE `sakila`.`menu` SET `className`='user' WHERE `id`='3';

这将更新className列从menu table到与为User类创建的xtype配置相匹配,这是我们希望在用户从菜单中选择用户选项时打开的视图。

重新加载项目后,我们将能够看到应用程序中所有用户的列表:

创建用户视图控制器

添加和编辑新用户

现在我们能够列出应用程序的所有用户,我们可以实现添加编辑按钮的功能。但在我们开始向控制器添加新的事件监听器之前,我们需要创建一个新视图,我们将向用户展示以编辑或添加新用户。

创建编辑视图 – 窗口内的表单

这个新的视图将是一个窗口,因为我们想将其显示为弹出窗口,在这个窗口内,我们将有一个包含用户信息的表单,然后,在底部将有一个包含两个按钮的工具栏:取消保存。这与我们在第三章,登录页面中开发的登录窗口非常相似,但我们将为此新表单添加新的功能,例如文件上传以及使用 HTML5 特性预览文件。

我们将要创建的视图看起来如下截图所示:

创建编辑视图 – 窗口内的表单

因此,让我们开始创建一个名为Packt.view.security.UserForm的新类,它将extend从窗口类继承:

Ext.define('Packt.view.security.UserForm', {
    extend: 'Ext.window.Window',
    alias: 'widget.user-form',

    height: 270,
    width: 600,

    requires: [
        'Packt.util.Util',
        'Packt.util.Glyphs'
    ],

    layout: {
        type: 'fit'
    },

    bind: {
        title: '{title}' //#1
    },

    closable: false,
    modal: true,

    items: [
        {
            xtype: 'form',
            reference: 'form',
            bodyPadding: 5,
            modelValidation: true, //#2
            layout: {
                type: 'hbox',      //#3
                align: 'stretch'
            },
            items: [
                //add form items here
            ]
        }
    ]
});

在本节课中,有三件非常重要的事情需要我们注意:第一点是,我们并没有使用autoShow属性。其目的是我们可以创建窗口,然后通过手动调用show()方法来显示它。

第二点是行#1中的数据绑定。我们希望使用数据绑定来自动设置窗口的title(添加新用户或编辑:用户名称)。这是 MVVM 架构的优点之一。

第三点是我们在表单上使用的layout。它不是表单组件默认使用的layout(即锚布局)。我们将使用hbox布局(#3),因为我们想水平组织表单的项目。并且我们希望项目占据所有可用的垂直空间,所以我们将使用align: 'stretch'——我们不想为每个表单items设置height

最后,我们在第三章,登录页面中学习了如何使用表单验证来验证表单。在本章中,我们将使用modelvalidations#2)来验证表单。

让我们将第一个项目添加到我们的表单中。如果我们查看本主题开头窗口的截图,我们会注意到我们将使用两个fieldset来组织表单items。因此,第一个将是一个fieldset来组织所有的"用户信息",如下所示:

{
    xtype: 'fieldset',
    flex: 1,                   //#4
    title: 'User Information',
    layout: 'anchor',          //#5
    defaults: {
        afterLabelTextTpl: Packt.util.Util.required, //#6
        anchor: '100%',                              //#7
        xtype: 'textfield',
        msgTarget: 'side',
        labelWidth: 75
    },
    items: [
        //add items here
    ]
},

由于表单使用的是hbox布局,我们需要指定这个组件将占用多少空间(#4)。当使用 HBox 或 VBox 布局时,子组件占用的空间是根据配置的相对空间计算的。如果我们有五个项目,每个项目的flex属性为1,那么总和将是五;每个项目将占用可用空间的一分之一。或者,我们也可以为某些项目设置width(HBox)或height(VBox),剩余的空间将分配给具有flex配置的项目。在这个例子中,我们将为这个fieldset使用flex: 1,而对于我们将要声明的下一个,我们将使用固定宽度,因此这个fieldset将占用所有剩余的可用空间。

fieldset还将使用anchor布局(#5),这允许您根据容器的尺寸锚定项目。anchor布局是表单的默认布局,但因为我们使用的是fieldset,所以我们也需要指定布局。对于每个项目,我们可以指定anchor配置(#7)。由于我们希望子项目占用fieldset内的所有可用宽度,我们将anchor配置设置为100%

对于所有必填项,我们将添加一个红色星号(#6)。我们不必为每个表单添加 HTML,我们可以将此值添加到我们的Util类中,并在其他表单中重用它。在Packt.Util.util类内部,添加以下代码:

required: '<span style="color:red;font-weight:bold" data-qtip="Required"> *</span>',

我们还告诉fieldset,项目的默认xtype将是textfield。如果声明的任何字段不需要这些默认配置,我们将用其他值覆盖它们。所以,让我们声明将成为"用户信息"字段集items配置部分的字段:

{
    xtype: 'hiddenfield',
    name: 'id',               //#8
    fieldLabel: 'Label',
    bind : '{currentUser.id}' //#9
},
{
    fieldLabel: 'Username',
    name: 'userName',
    bind : '{currentUser.userName}'
},
{
    fieldLabel: 'Name',
    name: 'name',
    bind : '{currentUser.name}'
},
{
    fieldLabel: 'Email',
    name: 'email',
    bind : '{currentUser.email}'
},
{
    xtype: 'combo',
    fieldLabel: 'Group',
    displayField: 'name',  //#10
    valueField: 'id',      //#11
    queryMode: 'local',    //#12
    forceSelection: true,  //#13
    editable: false,       //#14
    name: 'groups_id',
    bind: {
        value: '{currentUser.groups_id}', //#15
        store: '{groups}',                //#16
        selection: '{currentUser.group}'  //#17
    }
},
{
    xtype: 'filefield',
    fieldLabel: 'Photo',
    name: 'picture',
    buttonText: 'Select Photo...',
    afterLabelTextTpl: '',          //#18
    listeners: {
        change: 'onFileFieldChange' //#19
    }
}

id字段将被隐藏,因为我们不希望用户看到它(我们只会在内部使用它),而userNamenameemail是简单的文本字段。请注意,对于每个字段,我们声明了一个name#8)和bind#9)配置。由于我们打算使用文件上传功能,我们需要使用 Ajax 提交表单信息,这就是为什么我们需要为每个字段设置name配置。为了避免手动设置表单的值,我们将使用从 ViewModel 的数据绑定。我们将在 ViewModel 中设置一个名为currentUser的变量,它将引用UsersGrid中当前选中的行。

然后,我们有一个组合框。当与组合框一起工作时,我们需要设置一个存储来为其提供信息。在这种情况下,我们将绑定(#16),这个组合框的store与 ViewModel 中的groups存储。我们将在一分钟内创建存储。存储可以表示具有不同字段的模型。我们可以指定模型中哪个字段将用作内部值(#11)以及哪个字段将显示给用户(#10)。

我们还可以指定其他选项,例如强制用户从组合框(#13)中选择一个值,并阻止用户在其上写入任何内容(#14)——因为写入是自动完成的。由于我们已为这个组合框加载了存储,我们可以将查询模式设置为local#15)。默认行为是,每当用户点击组合框触发器时,都会加载存储。

注意,这个字段的绑定比其他字段更复杂。我们绑定了三个值;我们之前提到的存储(#16);一个值,它指的是User模型的外键groups_id#15)和selection#17),它将引用User模型中的group对象(引用选中的Group)。

然后,我们有文件上传字段。这个字段不是必填项,所以我们不希望它通过覆盖defaults配置来显示那个红色的星号(#18)。我们还想使用预览功能,因此我们还将为此字段添加一个listener声明(#19)。每当用户选择一个新的图片时,我们将在我们即将声明的字段集中显示它(我们将在本章后面讨论预览功能)。

这是将在表单左侧显示的第一个fieldset。接下来,我们需要声明另一个fieldset,它将包裹Photo并在表单右侧显示:

{
    xtype: 'fieldset',
    title: 'Photo',
    width: 170,  //#19
    items: [
        {
            xtype: 'image',
            reference: 'userPicture', //#20
            height: 150,
            width: 150,
            bind:{
                src: 'resources/profileImages/{currentUser.picture}' //#21
            }
        }
    ]
}

在这个fieldset中,我们将声明一个固定的width#19)。由于表单使用 HBox 布局,当一个组件具有固定width时,布局将尊重并应用指定的width。然后,具有flex配置的第一个fieldset将占据所有剩余的水平空间。

在图片字段集中,我们将使用Ext.Image ComponentExt.Image#20)类帮助我们创建和渲染图像。它还在 DOM 中创建一个带有src#21)指定的<image>标签。src属性也绑定到User模型的图片字段。我们还声明了一个引用,以便稍后使用预览功能(#20)。

当我们加载现有的User并尝试编辑表单时,我们将在这个组件上显示用户的图像(如果有)。此外,如果用户上传了新的图像,预览也将在这个组件中渲染。

现在,最后一步是声明带有保存取消按钮的底部工具栏,如下所示:

dockedItems: [
    {
        xtype: 'toolbar',
        dock: 'bottom',
        ui: 'footer', 
        layout: {
            pack: 'end', //#22
            type: 'hbox'
        },
        items: [
            {
                xtype: 'button',
                text: 'Save',
                glyph: Packt.util.Glyphs.getGlyph('save'),
                listeners: {
                    click: 'onSave'
                }
            },
            {
                xtype: 'button',
                text: 'Cancel',
                glyph: Packt.util.Glyphs.getGlyph('cancel'),
                listeners: {
                    click: 'onCancel'
                }
            }
        ]
    }
]

由于我们想要将按钮对齐在工具栏的右侧,我们将使用hbox布局并组织(#22)按钮到右侧工具栏。编辑/添加窗口现在已准备好。然而,在实现控制器上的添加和编辑监听器之前,我们还需要注意一些其他细节。

创建组模型

Group组合框中,我们声明了一个groups存储,用于从数据库中加载所有Groups。现在,我们需要实现这个缺失的存储,首先一步是创建一个将代表group表中组记录的模型。因此,我们将创建一个名为Packt.model.security.Group的新模型,如下所示:

Ext.define('Packt.model.security.Group', {
    extend: 'Packt.model.security.Base',

    fields: [
        { name: 'name' }
    ]
});

由于group表非常简单,它只包含两个列,idname;我们的Group模型也很简单,只包含这两个字段。由于Group模型是从本章开头创建的Base模型扩展的,id列将来自Baseschemaproxy配置。

组存储

既然我们已经创建了Group模型,现在我们需要创建groups存储。

注意

总是记住命名约定:模型名称是你想要表示的实体的单数名称,而存储是模型/实体名称的复数形式。

因此,我们将在UserModel类内部创建一个新的Store,如下所示:

stores: {
    users: {
        model: 'Packt.model.security.User',
        autoLoad: true
    },
    groups: {        model: 'Packt.model.security.Group',        autoLoad: true    }
}

按照其他存储相同的模式,groups信息将通过服务器在 JSON 中的数据属性发送,如下所示:

{
    "success": true,
    "data": [{
        "id": "1",
        "name": "admin"
    }]
}

现在所有用于我们用户管理模块的视图、模型和存储都已创建。我们可以专注于 ViewController 来处理我们感兴趣的所有事件,并实现所有魔法!

注意

对于本章的所有服务器端代码以及组管理代码,请下载本书的源代码包或访问github.com/loiane/masteringextjs

控制器 – 监听添加按钮

我们将实现的第一事件是编辑或添加窗口的添加事件。当用户点击添加按钮时,我们希望显示编辑用户窗口(Packt.view.security.UserForm类)。

按钮添加已经有一个监听器。所以我们只需要在 ViewController 中添加代码:

onAdd: function(button, e, options){
    this.createDialog(null);
},

如果用户点击添加按钮,我们希望打开一个空白弹出窗口,以便用户可以输入新记录信息并保存它。如果用户点击编辑按钮,我们希望打开包含从网格中选择的行数据的相同弹出窗口。因此,对于添加按钮,我们将传递 null(没有选择的行)来打开弹出窗口。createDialog方法在以下代码中列出:

createDialog: function(record){

    var me = this,           
        view = me.getView(); //#1

    me.dialog = view.add({
        xtype: 'user-form',  //#2
        viewModel: {         //#3
            data: {
                title: record ? 'Edit: ' + record.get('name') : 'Add User' //#4
            },
            links: { //#5
                currentUser: record || { //#6
                    type: 'User',        //#7
                    create: true
                }
            }
        }
    });

    me.dialog.show(); //#7
},

我们开始引用(#1User视图类引用,因为ModelView是在其中声明的。

接下来,我们将创建 UserForm 窗口(#2)并将其分配给属于 ViewModel 范围的变量 dialog(方法 add 返回创建的组件实例)。我们还将 UserForm 窗口添加到 User 视图(你可能记得我们使用了 VBox 布局而不是 Fit 布局;这就是原因)。当将 UserForm 窗口作为 User 视图的项目添加时,此项目也将能够访问其父级关联的 ViewModel。在这种情况下,我们想要向窗口的 ViewModel 添加更多详细信息(作为一个子 ViewModel —(#3))。我们将添加一个名为 title 的预定义字段(我们曾用它来设置窗口的标题 —(#4))。我们还将创建一个 链接 (#5)。链接提供了一种将简单名称分配给更复杂绑定的方式。这种用法的主要目的是为数据模型中的记录分配名称。如果存在一个现有的 record(来自 Edit —(#6)),它将使用它的副本,如果没有,它将创建一个新的幻影记录(#7)。

控制器 – 监听编辑按钮

如果我们想要编辑现有用户,编辑按钮将触发点击事件,ViewController 将通过以下方法监听它:

onEdit: function(button, e, options){

    var me = this,
        records = me.getRecordsSelected(); //#1

    if(records[0]){ //#2
        me.createDialog(records[0]); //#3
    }
},

首先,我们将从网格中获取所选的 records (#1)。如果选择了记录(#2),我们将创建一个窗口并传递记录(#3)。

getRecordSelected 方法如下列出:

getRecordsSelected: function(){
    var grid = this.lookupReference('usersGrid'); //#4
    return grid.getSelection(); //#5
},

我们将获取 UsersGrid 的引用(#4),通过访问其 getSelection 方法,我们可以获取所选的行(#5)。

getSelection 方法返回所选记录的数组。这就是为什么我们使用 records[0] 来访问所选行。默认情况下,一个网格允许你一次只选择一行。这可以通过在网格中设置以下配置来更改:

selModel: {
    mode: 'MULTI'
},

默认情况下,一个网格允许你一次只选择一行;这可以通过使用 selType: 'checkboxmodel' (Ext.selection.CheckboxModel) 来更改。

#2 中的验证是一个额外的步骤,因为我们直接将 编辑 按钮绑定到网格的 selection 配置,但为了谨慎并避免代码中的异常,永远都不嫌多!

控制器 – 监听取消按钮

如果用户决定不保存用户信息,可以点击 取消 按钮,这将触发点击事件以执行以下方法:

onCancel: function(button, e, options){
    var me = this;
    me.dialog = Ext.destroy(me.dialog);
},

我们想要做的事情非常简单:如果用户想要取消对现有用户所做的所有更改,或者想要取消创建用户,系统将销毁窗口。我们可以使用 Ext.destroy 来销毁它,或者也可以调用 destroy 方法。同时,me.dialog 也会失去引用。

注意

要了解更多关于 JavaScript 内存泄漏的信息,请访问 javascript.info/tutorial/memory-leaks。要了解更多关于垃圾收集器(释放内存)的重要性,请访问 goo.gl/qDdwwt

控制器 – 保存用户

现在用户能够打开窗口来创建或编辑一个 User,我们需要实现 保存 按钮的逻辑。无论用户是创建新用户还是编辑现有用户,我们都会使用相同的逻辑来保存用户。如果服务器端需要使用 UPDATEINSERT 查询,我们将让服务器端来处理。

ViewController 将执行以下方法来保存信息:

onSave: function(button, e, options){

    var me = this,
        form = me.lookupReference('form'); //#1

    if (form && form.isValid()) { //#2
        form.submit({     //#3
            clientValidation: true, //#4
            url: 'php/user/save.php', //#5
            scope: me,                //#6
            success: 'onSaveSuccess',
            failure: 'onSaveFailure'
        });
    }
},

第一步是获取表单引用(#1)。然后,我们将验证表单是否有效(#2 用户按照模型验证规则(#4)填写了有效的值,这些规则是我们将要实施的),之后我们将表单提交到指定的 url#5)。

我们可以使用 Store 功能来创建和编辑用户(正如我们将在本书后面看到的那样)。然而,我们正在使用不同的方法,即表单提交方法直接将值发送到服务器,因为我们还正在上传文档到服务器。在上传文档到服务器时,无法使用 Store 功能。

在我们列出成功和失败回调之前,再次看看代码中的这一行 var me = this。每次我们有一个以上的 this 引用或者我们在处理回调时,我们都会进行这个赋值。

注意

使用 me 而不是 this(或者你也可以根据你的喜好创建其他变量名,比如 thatself)有两个原因。第一个原因是在一个方法中大量使用 this 时,使用 me 可以在每个引用上节省 16 位。在我们完成生产构建后,Sencha Cmd 将会替换 meab 或其他任何字母。关键字 this 不能被替换为 ab 或其他任何值,因此它将使用四个字符而不是一个字符。

第二个原因是我们可以保持对 this 的引用,在 this 指向其他内容的作用域内(比如回调函数,例如,表单的 submit——如果我们使用 thissubmit 中,它将引用 submit 方法本身而不是 ViewController)。

这样,回调函数可以引用在外部函数(在这个例子中是 ViewController)中声明的函数或变量。这被称为闭包。

你可以通过从这本书下载源代码来了解如何在 PHP 中处理文件上传。如果你使用的是其他语言,或者由于某种原因表单提交不起作用,请始终检查你使用的浏览器中的 开发者工具,以查看发送到服务器的信息。以下截图展示了创建新用户时发送的内容:

控制器 – 保存用户

下一步是实现successfailure回调。让我们首先实现success回调:

onSaveSuccess: function(form, action) {
    var me = this;
    me.onCancel(); //#7
    me.refresh();  //#8
    Packt.util.Util.showToast('Success! User saved.'); //#9
},

如果服务器返回successtrue,我们将调用负责关闭和销毁窗口(#7)的onCancel方法,该窗口是在前一个主题中实现的。由于我们使用表单提交将信息发送到服务器,我们需要刷新(#8)Store 以从服务器获取新信息。最后,我们将显示一个吐司(在 Ext JS 5 中引入)并显示成功消息(#9),如下面的图像所示:

控制器 – 保存用户

刷新方法如下所示:

refresh: function(button, e, options){
    var me = this,
        store = me.getStore('users');

    store.load();
},

refresh方法内部,我们获取users Store 的引用并调用其load方法,再次从服务器获取信息。

以下是从Packt.util.Util类中的showToast静态方法:

showToast: function(text) {
    Ext.toast({
        html: text,
        closable: false,
        align: 't',
        slideInDuration: 400,
        minWidth: 400
    });
}

Ext.Toast 类提供了轻量级、自动消失的弹出通知,称为吐司。我们可以设置其内容(html)、标题、关闭按钮、对齐方式(在我们的示例中,它将在顶部显示)、显示时长(4 秒)以及其宽度,以及其他我们可以在 Ext JS 文档中检查的选项。

接下来,让我们实现failure回调:

onSaveFailure: function(form, action) {
    Packt.util.Util.handleFormFailure(action);
},

在第三章,“登录页面”,我们也处理了表单失败回调。我们将使用这里的代码完全相同。由于我们开始重复代码,我们可以在Util类中创建另一个静态函数,以便我们可以重用它:

handleFormFailure: function(action){
    var me = this,
    result = Packt.util.Util.decodeJSON(action.response.responseText);

    switch (action.failureType) {
        case Ext.form.action.Action.CLIENT_INVALID:
            me.showErrorMsg('Form fields may not be submitted with invalid values'); //#1
            break;
        case Ext.form.action.Action.CONNECT_FAILURE:
            me.showErrorMsg(action.response.responseText);
            break;
        case Ext.form.action.Action.SERVER_INVALID:
            me.showErrorMsg(result.msg);
    }
}

现在的区别是,这段代码位于Packt.util.Util类中,我们可以引用它来调用showErrorMsg方法(#1)。

我们还可以回到LoginController,并用handleFormFailure函数的调用替换失败回调代码。

我们现在的保存代码已经准备好了。

使用模型验证器

在我们实现了保存方法后,让我们利用这个机会来完成代码,以使用模型验证器验证表单。

我们将向User模型添加以下代码:

validators: {
    name: [
        { type: 'presence', message: 'This field is mandatory'},
        { type: 'length', min: 3, max: 100}
    ],
    userName: [
        { type: 'exclusion', list: ['Admin', 'Operator'] },
        { type: 'format', matcher: /([a-z]+)/i },
        { type: 'presence', message: 'This field is mandatory'},
        { type: 'length', min: 3, max: 25}
    ],
    email: [
        { type: 'presence', message: 'This field is mandatory'},
        { type: 'length', min: 5, max: 100},
        { type: 'email' }
    ],
    groups_id: 'presence'
},

我们可以有以下类型的模型验证器:

  • presence: 这确保了字段有一个值。零被视为有效值,但空字符串不算。

  • length: 这确保字符串在min长度和max长度之间。这两个约束都是可选的。

  • format: 这确保字符串匹配正则表达式格式。

  • inclusion: 这确保值在特定的值集中(例如,确保性别是男性或女性)。

  • exclusion: 这确保值不是特定集合中的任何一个值(例如,将用户名如“admin”列入黑名单)。

  • email: 这确保值是有效的电子邮件。

  • range: 这确保值在minmax之间。这两个约束都是可选的。

每个验证器都有一个默认的message,以防验证未通过。我们也可以覆盖它。

在上传前预览文件

最后一件我们将实现与窗口相关的事情:文件上传预览。这是一件不太难实现的事情,会给应用程序用户带来惊喜!

因此,当用户使用文件上传组件选择新文件时,我们想要做的是使用 HTML5 FileReader API 读取文件。不幸的是,并非所有浏览器都支持 FileReader API;只有以下版本支持:Chrome 6+、Firefox 4+、Safari 6+、Opera 12+、Explorer 10+、iOS Safari 6+、Android 3+ 和 Opera Mobile 12+。但不用担心,我们首先会验证浏览器是否支持它,如果不支持,我们则不会使用它,这意味着文件预览将不会发生。

注意

要了解更多关于 FileReader API 的信息,请阅读其规范www.w3.org/TR/file-upload/,以及更多关于此和其他 HTML5 功能的信息,请访问www.html5rocks.com/

当用户使用 Ext JS 文件上传组件选择新文件时,会触发一个改变事件,因此我们需要在我们的 ViewController 中监听它。以下代码示例说明了本段讨论的内容:

onFileFieldChange: function(fileField, value, options) {

    var me = this,
        file = fileField.fileInputEl.dom.files[0], //#1
        picture = this.lookupReference('userPicture'); //#2

    if (typeof FileReader !== 'undefined' && (/image/i).test(file.type)) { //#3
        var reader = new FileReader();       //#4
        reader.onload = function(e){         //#5
            picture.setSrc(e.target.result); //#6
        };
        reader.readAsDataURL(file);          //#7
    } else if (!(/image/i).test(file.type)){ //#8
        Ext.Msg.alert('Warning', 'You can only upload image files!');
        fileField.reset();                   //#9
    }
}

因此,首先,我们需要获取存储在 Ext JS 文件字段组件文件输入元素中的文件对象(也作为参数传递给我们的方法)。然后,我们将获取我们表单中 Ext.Image 组件的引用,以便我们可以更新其 source 为文件预览。

我们还将测试浏览器上是否可用 FileReader API,以及用户选择的文件是否为图像(#3)。如果为正,我们将实例化一个 FileReader 方法(#4);我们将向它添加一个监听器(#4),这样当 FileReader 完成读取文件后,我们可以将其内容设置为 Ext.Image 的源(#6)。当然,为了触发 onload 事件,FileReader 实例需要读取文件的内容(#7)。一个非常重要的注意事项:我们在上传到服务器之前显示文件的内容。如果用户保存对表单所做的更改,新的用户信息将包括文件上传发送到服务器,下次我们打开窗口时,图片将显示出来。

注意

你如何获取正在上传的文件的全路径?例如,Ext JS 文件上传组件显示 C:\fakepath\nameOfTheFile.jpg,而我们想要获取其实际路径,例如 C:\Program Files\nameOfTheFile.jpg。答案是:无法使用 JavaScript(以及 Ext JS 是一个 JavaScript 框架)来实现这一点。

这不是 Ext JS 的限制;如果我们尝试使用任何其他 JavaScript 框架或库,例如 jQuery,这也不可能实现,因为这是一个浏览器安全限制。想象一下如果这是可能的。有人可以开发一个恶意的 JavaScript 文件,在你上网导航时运行它,并获取你电脑上的所有信息。

另一件非常不错的事情是:如果用户选择的文件不是图片(#8),我们将显示一条消息说明只能上传图片,并且我们将重置文件上传组件。不幸的是,在浏览窗口(打开以便我们从电脑中选择文件的那个窗口)中无法过滤文件类型,这是一个变通方法,因此我们可以在 Ext JS 端进行此验证,而不是将其留给服务器。

如果 FileReader 不可用,将不会发生任何事情。文件预览将无法正常工作。用户将选择文件,然后就没有然后了。

小贴士

您可以上传的文件大小限制取决于您将要部署 Ext JS 应用程序的 Web 服务器上设置的文件上传限制。例如,Apache 支持高达 2GB 的限制。IIS 的默认值为 4MB,但您可以将其增加到 2GB。Apache Tomcat 和其他 Web 服务器也是如此。因此,大小限制不在 Ext JS 中;它在于 Web 服务器,您只需进行配置即可。

删除用户

我们需要实现的最后一个 CRUD 操作是删除用户。因此,让我们向 ViewController 添加删除监听器,如下所示:

onDelete: function(button, e, options){
    var me = this,
        view = me.getView(),
        records = me.getRecordsSelected(), //#1
        store = me.getStore('users');      //#2

    if (store.getCount() >= 2 && records.length){ //#3
        Ext.Msg.show({
            title:'Delete?', //#4
            msg: 'Are you sure you want to delete?',
            buttons: Ext.Msg.YESNO,
            icon: Ext.Msg.QUESTION,
            fn: function (buttonId){
                if (buttonId == 'yes'){ //#5
                    store.remove(records); //#6
                    store.sync();          //#7
                }
            }
        });
    } else if (store.getCount() === 1) { //#8
        Ext.Msg.show({
            title:'Warning',
            msg: 'You cannot delete all the users from the application.',
            buttons: Ext.Msg.OK,
            icon: Ext.Msg.WARNING
        });
    }
},

这种方法的想法是验证用户是否已从网格中选择任何要删除的行(record[0] 存在——#1),并且我们只有在应用程序中有超过两个用户的情况下才会删除用户(#3)。如果是这样,我们将删除用户。如果不是,这意味着应用程序中只有一个用户(#8),我们无法删除唯一存在的用户。

如果可以删除用户,系统将显示一个询问是否真的想要删除所选用户(#4)的问题。如果答案是 yes#5),我们将获取 store 引用(#2)并使用其 remove 方法(#6)传递要删除的记录,并将此请求发送到服务器(#7)。当调用 sync 方法时,proxy 将调用 destroy URL。

注意

只需记住,在服务器上,您可以在数据库上执行一个 DELETE 查询,但在大多数情况下我们进行逻辑删除,这意味着我们将对 active 列执行 UPDATE 操作(在这种情况下,将用户更新为非活动状态)。

在网格中显示组名

我们有几种方法可以在网格中显示关联数据。在这个例子中,我们将使用一种方法,在其他章节中,我们将使用不同的方法。

我们将要使用的方法是向 User 模型添加一个 hasOne 关联,如下所示:

hasOne: [
    {
        model: 'Group',         //#1
        name: 'group',          //#2
        foreignKey:'groups_id', //#3
        associationKey: 'group'
    }
]

由于我们在 UserGroup 模型中使用相同的模式,我们可以仅通过其 entityName#1)来引用 Group 模型。我们还可以为从服务器返回的包含 Group 信息的对象指定一个名称(#2)。最后,Ext JS 还需要知道哪个字段包含到 Group 模型的外键(#3)。

然后,我们将在用户模型中添加一个新字段,如下所示:

{ name:'groupName', type:'string', persist:false,
    convert:function(v, rec){
        var data = rec.data;
        if (data.group && data.group.name){
            return data.group.name;
        }
        return data.groups_id;
    }
}

当创建一个User模型时,该字段将在运行时创建。我们不会持久化此信息,这意味着每当存储向服务器发送创建、更新或销毁请求时,此字段将不会被包含。对于此字段,我们还将声明一个convert函数,这意味着此字段的信息将从另一个现有字段创建。如果有group信息可用,我们将返回其name;否则,无论如何都会返回groups_id

然后,在UsersGrid类中,我们将用以下代码替换当前groups_id列的dataIndex

dataIndex: 'groupName',

并且网格将显示组的名称而不是其 ID。

我们可以刷新应用程序并测试本章中所有功能!

摘要

在本章中,我们介绍了如何在我们的应用程序中创建、更新、删除和列出所有用户。

在开发此模块的过程中,我们涵盖了 Ext JS 的一些重要概念和在 Ext JS 5 中引入的一些功能。我们使用 MVVM 开发了此模块,并介绍了其他 ModelView 功能,例如数据绑定。我们学习了如何在 Model 中使用模式并使用 Model 验证来验证表单。我们还探索了 HTML5 的一个新特性,用于文件上传预览功能,这也是我们如何结合使用其他技术以及 Ext JS 的另一个示例。

在下一章中,我们将实现 MySQL 表管理模块,这意味着我们将实现一个与 MySQL Workbench 应用程序中找到的编辑表数据屏幕非常相似的屏幕。

第七章:静态数据管理

到目前为止,我们已经实现了与应用程序基本功能相关的功能。从现在开始,我们将开始实现应用程序的核心功能,从静态数据管理开始。这究竟是什么?每个应用程序都有与核心业务不直接相关的信息,但以某种方式被核心业务逻辑使用。这就是我们称之为静态数据的原因,因为它不经常改变。还有动态数据,这是在应用程序中变化的信息,我们称之为核心业务数据。客户、订单和销售将是动态或核心业务数据的例子。

每个应用程序中都有两种类型的数据:静态数据和动态数据。例如,类别、语言、城市和国家类型可以独立于核心业务存在,也可以被核心业务信息使用;这就是我们称之为静态数据的原因,因为它不经常改变。还有动态数据,这是在应用程序中变化的信息,我们称之为核心业务数据。客户、订单和销售将是动态或核心业务数据的例子。

我们可以将这些静态信息视为独立的 MySQL 表格(因为我们使用 MySQL 作为数据库服务器),我们可以执行在 MySQL 表格上可以执行的所有操作。因此,在本章中,我们将涵盖:

  • 创建一个名为静态数据的新系统模块

  • 将所有信息作为 MySQL 表格列出

  • 在表格上创建新记录

  • 表格上的实时搜索

  • 过滤信息

  • 编辑和删除记录

  • 创建一个用于所有表格重用的抽象组件

展示表格

如果我们打开并分析随 Sakila 安装提供的实体关系ER)图,我们会注意到以下表格:

展示表格

注意

作为提醒,Sakila 数据库可以从dev.mysql.com/doc/index-other.html下载,其文档和安装说明可在dev.mysql.com/doc/sakila/en/找到。

这些表格可以独立于其他表格存在,我们将在本章中与它们一起工作。

当我们在 MySQL Workbench(版本 6)中打开 SQL 编辑器时(dev.mysql.com/downloads/workbench/),我们可以选择一个表格,右键单击它,并选择选择行 – 限制 1000。当我们选择此选项时,将打开一个新标签页,其外观如下:

展示表格

之前显示的表格是actor表格。我们的想法是为我们选择的每个表格实现类似于前一个屏幕截图的屏幕:演员类别语言城市国家,如以下屏幕截图所示(这是我们将在本章中实现的代码的最终结果):

展示表格

我们在本章的目标是尽量减少实现这五个屏幕所需的代码量。这意味着我们希望尽可能创建最通用的代码,这将有助于未来的代码修复和增强,并且如果需要,也更容易创建具有相同功能的新的屏幕。

那么,让我们开始开发吧。

创建模型

如往常一样,我们将从创建模型开始。首先,让我们列出我们将要处理的表及其列:

  • Actor: actor_id, first_name, last_name, last_update

  • Category: category_id, name, last_update

  • Language: language_id, name, last_update

  • City: city_id, city, country_id, last_update

  • Country: country_id, country, last_update

我们可以为这些实体中的每一个创建一个模型,完全没有问题;然而,我们希望尽可能多地重用代码。再次查看表和列的列表。注意,所有表都有一个共同的列——last_update列。

所有的前一个表都有一个共同的last_update列。换句话说,我们可以创建一个包含此字段的超级模型。当我们实现actorcategory模型时,我们可以扩展超级模型,在这种情况下,我们不需要声明列。你不这么认为吗?

抽象模型

在面向对象编程(OOP)中,有一个称为继承的概念,这是一种重用现有对象代码的方式。Ext JS 使用面向对象的方法,因此我们可以在 Ext JS 应用程序中应用相同的概念。如果你回顾一下我们已实现的代码,你会注意到我们已经在大多数类中应用了继承(除了util包),但我们正在创建继承自 Ext JS 类的类。现在,我们将开始创建我们自己的超级类。

由于我们将要处理的模型都具有共同的last_update列(如果你看一下,所有的 Sakila 表都有这个列),我们可以创建一个包含此字段的超级模型。因此,我们将在app/model/staticData下创建一个新的文件,命名为Base.js

Ext.define('Packt.model.staticData.Base', {
    extend: 'Packt.model.Base', //#1

    fields: [
        {
            name: 'last_update',
            type: 'date',
            dateFormat: 'Y-m-j H:i:s'
        }
    ]
});

此模型只有一个列,即last_update。在表中,last_update列的类型是timestamp,因此字段的type需要是date,我们还将应用date format: 'Y-m-j H:i:s',这是年、月、日、时、分、秒,遵循与数据库中相同的格式(2006-02-15 04:34:33)。

当我们可以为每个表示表的模型创建模型时,我们就不需要再次声明last_update字段。

再次查看第#1行的代码。我们没有扩展默认的Ext.data.Model类,而是另一个Base类。还记得我们在上一章中创建的security.Base模型吗?我们将将其代码移动到模型包中并进行一些修改。

适配 Base 模型架构

app/model文件夹内创建一个名为Base.js的文件,并在其中包含以下内容:

Ext.define('Packt.model.Base', {
    extend: 'Ext.data.Model',

    requires: [
        'Packt.util.Util'
    ],

    schema: {
        namespace: 'Packt.model', //#1
        urlPrefix: 'php',
        proxy: {
            type: 'ajax',
            api :{
                read : '{prefix}/{entityName:lowercase}/list.php',
                create: 
                    '{prefix}/{entityName:lowercase}/create.php',
                update: 
                    '{prefix}/{entityName:lowercase}/update.php',
                destroy: 
                    '{prefix}/{entityName:lowercase}/destroy.php'
            },
            reader: {
                type: 'json',
                rootProperty: 'data'
            },
            writer: {
                type: 'json',
                writeAllFields: true,
                encode: true,
                rootProperty: 'data',
                allowSingle: false
            },
            listeners: {
                exception: function(proxy, response, operation){
              Packt.util.Util.showErrorMsg(response.responseText);
                }
            }
        }
    }
});

与我们在上一章中实现的代码相比,唯一的区别是namespace#1)。我们不再使用Packt.model.security,而是将仅使用Packt.model

我们在上一章中创建的Packt.model.security.Base类现在看起来会更简单,如下所示:

Ext.define('Packt.model.security.Base', {
    extend: 'Packt.model.Base',

    idProperty: 'id',

    fields: [
        { name: 'id', type: 'int' }
    ]
});

它与我们为本章创建的staticData.Base模型非常相似。区别在于staticData包(last_update)和security包(id)共有的字段。

现在应用程序只有一个模式,这意味着模型的entityName将基于'Packt.model'之后的名称创建。这意味着我们在上一章中创建的UserGroup模型将分别具有entityName security.Usersecurity.Group。然而,我们不希望破坏已经实现的代码,因此我们希望UserGroup模型类具有entityName UserGroup。我们可以通过向User模型添加entityName: 'User'和向Group模型添加entityName: 'Group'来实现这一点。我们也将对接下来要创建的特定模型做同样的事情。

对于应用程序中的所有模型都有一个超级Base模型,这意味着我们的模型将遵循一个模式。代理模板对所有模型也是通用的,这意味着我们的服务器端代码也将遵循一个模式。这对于组织应用程序和未来的维护是有益的。

特定模型

现在,我们可以创建代表每个表的模型。让我们从 Actor 模型开始。我们将创建一个名为Packt.model.staticData.Actor的新类;因此,我们需要在app/model/staticData下创建一个名为Actor.js的新文件,如下所示:

Ext.define('Packt.model.staticData.Actor', {
    extend: 'Packt.model.staticData.Base', //#1

    entityName: 'Actor', //#2

    idProperty: 'actor_id', //#3

    fields: [
        { name: 'actor_id' },
        { name: 'first_name'},
        { name: 'last_name'}
    ]
});

在前面的代码中,我们需要注意三个重要的事项:

  • 此模型扩展自Packt.model.staticData.Base类,该类又扩展自Packt.model.Base类,而Packt.model.Base类又扩展自Ext.data.Model类。这意味着此模型继承了Packt.model.staticData.BasePackt.model.BaseExt.data.Model类的所有属性和行为。

  • 由于我们使用Packt.model模式创建了一个超级模型,因此为该模型创建的默认entityName将是staticData.Actor。我们使用entityName来帮助代理编译带有entityNameurl模板。为了使我们的工作更简单,我们将重写entityName#2)。

  • 第三点是idProperty#3)。默认情况下,idProperty的值是"id"。这意味着当我们声明一个名为"id"的字段时,Ext JS 已经知道这是该模型的唯一字段。当它不同于"id"时,我们需要使用idProperty配置来指定它。由于所有 Sakila 表都没有名为"id"的唯一字段——它总是实体名称 + "_id",因此我们需要在所有模型中声明此配置。

现在,我们可以为其他模型做同样的事情。我们需要创建四个更多类:

  • Packt.model.staticData.Category

  • Packt.model.staticData.Language

  • Packt.model.staticData.City

  • Packt.model.staticData.Country

最后,我们将在 app/model/staticData 包内创建六个模型类(一个超级模型和五个特定模型)。如果我们为模型类创建一个 UML 类图,我们将得到以下图示:

特定模型

ActorCategoryLanguageCityCountry 模型扩展了 Packt.model.staticData 基础模型,该模型从 Packt.model.Base 扩展而来,而 Packt.model.Base 又从 Ext.data.Model 类扩展而来。

创建 Store

下一步是为每个模型创建 Store。就像我们对模型所做的那样,我们将尝试创建一个通用 Store(在本章中,我们将为所有屏幕创建通用代码,因此创建超级模型、Store 和 View 是能力的一部分)。尽管通用配置不在 Store 中,但在 Proxy 中(我们在 Packt.model.Base 类的 schema 中声明了它),拥有一个超级 Store 类可以帮助我们监听所有静态数据 Store 的通用事件。

我们将创建一个名为 Packt.store.staticData.Base 的超级 Store。

由于我们需要为每个模型创建一个 Store,我们将创建以下 Store:

  • Packt.store.staticData.Actors

  • Packt.store.staticData.Categories

  • Packt.store.staticData.Languages

  • Packt.store.staticData.Cities

  • Packt.store.staticData.Countries

在本主题结束时,我们将创建所有之前的类。如果我们为它们创建一个 UML 图,我们将得到如下所示的图示:

创建 Store

所有 Store 类都扩展自 Base Store。

现在我们知道了我们需要创建什么,让我们动手吧!

抽象 Store

我们需要创建的第一个类是 Packt.store.staticData.Base 类。在这个类中,我们只声明 autoLoadtrue,以便所有这个 Store 的子类在应用程序启动时都可以被加载:

Ext.define('Packt.store.staticData.Base', {
    extend: 'Ext.data.Store',

    autoLoad: true
});

我们将要创建的所有特定 Store 都将扩展这个 Store。创建这样一个超级 Store 可能感觉没有意义;然而,我们不知道在未来的维护中,我们是否需要添加一些通用的 Store 配置。

由于我们将在这个模块中使用 MVC,另一个原因是我们在 Controller 中也可以监听 Store 事件(自 Ext JS 4.2 版本起可用)。如果我们想监听一组 Store 的相同事件,并且执行完全相同的方法,拥有一个超级 Store 可以节省我们一些代码行。

特定 Store

我们接下来的步骤是实现 ActorsCategoriesLanguagesCitiesCountries 存储。

因此,让我们从 Actors Store 开始:

Ext.define('Packt.store.staticData.Actors', {
    extend: 'Packt.store.staticData.Base', //#1

    model: 'Packt.model.staticData.Actor' //#2
});

在定义 Store 之后,我们需要从 Ext JS Store类扩展。由于我们使用的是超级 Store,我们可以直接从超级 Store 扩展(#1),这意味着从Packt.store.staticData.Base类扩展。

接下来,我们需要声明这个 Store 将要表示的fieldsmodel。在我们的例子中,我们总是声明 Model(#2)。

注意

在 Store 中使用model对于重用是有好处的。fields配置建议仅在需要创建一个非常具体的 Store,其中包含我们不打算在整个应用程序中重用的特定数据时使用,例如在图表或报告中。

对于其他存储,唯一将要不同的事情是 Store 和 Model 的名称。

注意

由于模型和存储非常相似,我们不会在本章中列出它们的代码。然而,如果您需要与您的代码进行比较或只是想获取完整的源代码,您可以从本书中下载代码包或从github.com/loiane/masteringextjs获取。

创建用于重用的抽象 GridPanel

现在是实施视图的时候了。我们必须实现五个视图:一个用于执行 Actor 的 CRUD 操作,一个用于 Category,一个用于 Language,一个用于 City,还有一个用于 Country。

以下截图表示在实现Actors屏幕后我们想要达到的最终结果:

创建用于重用的抽象 GridPanel

以下截图表示在实现Categories屏幕后我们想要达到的最终结果:

创建用于重用的抽象 GridPanel

您注意到这两个屏幕之间有什么相似之处吗?让我们再看一遍:

创建用于重用的抽象 GridPanel

顶部的工具栏是相同的(1);有一个实时搜索功能(2);有一个过滤器插件(4),以及最后更新和部件列也是通用的(3)。再进一步,两个 GridPanels 都可以使用单元格编辑器进行编辑(类似于 MS Excel 的功能,您可以点击单个单元格来编辑它)。这两个屏幕之间唯一的不同之处在于每个屏幕特有的列(5)。这意味着如果我们通过创建一个具有所有这些通用功能的超级 GridPanel 来使用继承,我们可以重用代码的一部分吗?是的!

所以这就是我们要做的。让我们创建一个名为Packt.view.staticData.BaseGrid的新类,如下所示:

Ext.define('Packt.view.staticData.BaseGrid', {
    extend: 'Ext.ux.LiveSearchGridPanel', //#1
    xtype: 'staticdatagrid',

    requires: [
        'Packt.util.Glyphs' //#2
    ],

    columnLines: true,    //#3
    viewConfig: {
        stripeRows: true //#4
    },

    //more code here
});    

我们将扩展Ext.ux.LiveSearchGridPanel类而不是Ext.grid.PanelExt.ux.LiveSearchGridPanel类已经扩展了Ext.grid.Panel类,并添加了实时搜索工具栏(2)。LiveSearchGridPanel类是随 Ext JS SDK 一起分发的插件。因此,我们不需要担心将其手动添加到我们的项目中(你将在本书后面的章节中学习如何将第三方插件添加到项目中)。

由于我们还将添加带有添加保存更改取消更改按钮的工具栏,我们需要引入我们创建的util.Glyphs类(#2)。

配置#3#4显示了网格中每个单元格的边框,并在白色背景和浅灰色背景之间交替。

同样,任何负责在 Ext JS 中显示信息的组件,例如“面板”部分,只是外壳。视图负责在 GridPanel 中显示列。我们可以使用viewConfig#4)来自定义它。

下一步是创建一个initComponent方法。

初始化组件或不初始化组件?

在浏览其他开发者的代码时,我们可能会看到一些人在声明 Ext JS 类时使用initComponent,而另一些人则没有使用(就像我们之前所做的那样)。那么使用它和不使用它的区别是什么?

在声明 Ext JS 类时,我们通常根据应用程序的需求进行配置。它们可能成为其他类的父类,也可能不是。如果它们成为父类,一些配置将被覆盖,而一些则不会。通常,我们将我们预期要覆盖的类中的配置声明为配置。我们在initComponent方法中声明我们不希望被覆盖的配置。

由于有一些配置我们不希望被覆盖,我们将它们声明在initComponent中,如下所示:

initComponent: function() {
    var me = this;

    me.selModel = {
        selType: 'cellmodel' //#5
    };

    me.plugins = [
        {
            ptype: 'cellediting',  //#6
            clicksToEdit: 1,
            pluginId: 'cellplugin'
        },
        {
            ptype: 'gridfilters'  //#7
        }
    ];

    //docked items

    //columns

    me.callParent(arguments); //#8
}

我们可以定义用户如何从 GridPanel 中选择信息:默认配置是Selection RowModel类。因为我们希望用户能够逐个编辑单元格,我们将使用Selection CellModel类(#5)以及CellEditing插件(#6),它是 Ext JS SDK 的一部分。对于CellEditing插件,我们配置单元格在用户点击单元格时可供编辑(如果我们需要用户双击,我们可以将clicksToEdit改为2)。为了帮助我们在 Controller 中后续操作,我们还为此插件分配了一个 ID。

为了能够过滤信息(实时搜索将仅突出显示匹配的记录),我们将使用过滤器插件(#7)。过滤器插件也是 Ext JS SDK 的一部分。

callParent方法(#8)将调用超类Ext.ux.LiveSearchGridPanel中的initConfig,并传递我们定义的参数。

小贴士

在重写initComponent方法时忘记包含callParent调用是一个常见的错误。如果组件无法正常工作,请确保你已经调用了callParent方法!

接下来,我们将声明 dockedItems。由于所有 GridPanels 都将具有相同的工具栏,我们可以在我们创建的父类中声明 dockedItems,如下所示:

me.dockedItems = [
    {
        xtype: 'toolbar',
        dock: 'top',
        itemId: 'topToolbar', //#9
        items: [
            {
                xtype: 'button',
                itemId: 'add', //#10
                text: 'Add',
                glyph: Packt.util.Glyphs.getGlyph('add')
            },
            {
                xtype: 'tbseparator'
            },
            {
                xtype: 'button',
                itemId: 'save', 
                text: 'Save Changes',
                glyph: Packt.util.Glyphs.getGlyph('saveAll')
            },
            {
                xtype: 'button',
                itemId: 'cancel',
                text: 'Cancel Changes',
                glyph: Packt.util.Glyphs.getGlyph('cancel')
            },
            {
                xtype: 'tbseparator'
            },
            {
                xtype: 'button',
                itemId: 'clearFilter',
                text: 'Clear Filters',
                glyph: Packt.util.Glyphs.getGlyph('clearFilter')
            }
        ]
    }
];

我们将会有 添加保存更改取消更改清除过滤器 按钮。请注意,工具栏(#9)和每个按钮(#10)都声明了 itemId。由于我们将在本例中使用 MVC 方法,我们将声明一个控制器。itemId 配置具有与我们在与 ViewController 一起工作时声明的引用类似的责任。我们将在本章稍后声明控制器时进一步讨论 itemId 的重要性。

小贴士

在工具栏内部声明按钮时,我们可以省略 xtype: 'button' 配置,因为按钮是工具栏的默认组件。

Glyphs 类中,我们需要在其 config 中添加以下属性:

saveAll: 'xf0c7',
clearFilter: 'xf0b0'

最后,我们将添加所有屏幕都通用的两列(最后更新 列和 Widget Column 的 delete (#13))以及已在每个特定 GridPanel 中声明的列:

me.columns = Ext.Array.merge( //#11
    me.columns,               //#12
    [{
        xtype    : 'datecolumn',
        text     : 'Last Update',
        width    : 150,
        dataIndex: 'last_update',
        format: 'Y-m-j H:i:s',
        filter: true
    },
    {
        xtype: 'widgetcolumn', //#13
        width: 45,
        sortable: false,       //#14
        menuDisabled: true,    //#15
        itemId: 'delete',
        widget: {
            xtype: 'button',   //#16
            glyph: Packt.util.Glyphs.getGlyph('destroy'),
            tooltip: 'Delete',
            scope: me,                //#17
            handler: function(btn) {  //#18
                me.fireEvent('widgetclick', me, btn);
            }
        }
    }]
);

在前面的代码中,我们将 (#11) me.columns (#12) 与另外两个 columns 合并,并将此值赋给 me.columns。我们希望所有子网格都拥有这两列加上每个子网格的特定列。如果 BaseGrid 类中的列配置在 initConfig 之外,那么当子类声明其自己的列配置时,该值将被覆盖。如果我们将在 initComponent 中声明 columns 配置,子类将无法添加自己的 columns 配置,因此我们需要合并这两个配置(子类 #12 的列与每个子类都希望拥有的两列)。

对于删除按钮,我们将使用一个 Widget Column (#13)(在 Ext JS 5 中引入)。在 Ext JS 4 之前,在网格列内放置按钮的唯一方法是使用操作列。我们将使用一个按钮 (#16) 来表示 Widget Column。因为它是一个 Widget Column,所以没有必要使此列 可排序 (#14),我们还可以禁用其菜单 (#15)。

我们将在下一节中讨论 #17#18 行。

在 MVC 架构中处理 Widget Column

让我们再次看看在 super GridPanel 中声明的 Widget Column,特别是其在 handler 配置中的内容:

scope: me,                //#17
handler: function(btn) {  //#18
    me.fireEvent('widgetclick', me, btn);
}

尽管 Widget Column 是一个组件并且包含 xtype,但在 MVC 控制器中无法监听其事件,因此我们需要一个解决方案使其在 MVC 架构中工作。原因是 Widget Column 内部可以声明的项是 Ext.Widget 类的子类,而 Ext.Widget 类是 Ext.Evented 类的子类。MVC 控制器只能监听由组件子类(面板、按钮、网格、树、图表等)触发的事件。

正因如此,我们触发了一个自定义事件 (#18),传递我们需要的参数,这样我们就可以在这个控制器中捕获此事件,并处理删除记录所需的编程逻辑。

在这个例子中,我们将处理器的 scope 设置为 me (#17),它指的是 GridPanel 的 this。这意味着将会是 GridPanel 触发 widgetclick 事件,传递网格本身和 widget 按钮。btn 参数包含一个名为 getWidgetRecord 的方法,用于检索用户点击 删除 按钮时 GridPanel 行所代表的模型。

如果我们使用动作列(在 Ext JS 4 之前是一个非常受欢迎的选择)我们会在 MVC 架构中以相同的方式处理它(触发自定义事件)。一个例子可以在:goo.gl/pxdU4i 找到。

注意

在 MVVM 方法中处理 Widget 列处理器更为简单。我们不需要触发一个自定义事件,而可以直接引用 ViewController 中使用的方法,如下所示:handler: 'onWidgetClick'

Live Search 插件与 Filter 插件

这两个插件的目标都是帮助用户快速搜索信息。在我们的项目中,我们正在使用这两个插件。

Live Search 插件将在 GridPanel 的所有列中搜索任何匹配的结果。搜索也是本地执行的,这意味着如果我们使用分页工具栏,此插件将无法按预期工作。当使用分页工具栏时,网格一次只显示一页,这意味着它只显示有限数量的行。剩余的信息不会保留在本地,分页工具栏只获取请求的信息,这就是为什么在分页时搜索不会工作。在我们的情况下,我们一次性显示数据库中的所有记录,因此插件按预期工作。例如,如果我们搜索 "ada",我们将得到以下输出:

Live Search 插件与 Filter 插件对比

并且 Filter 插件也会在 Store 上应用过滤器,因此它只会向用户显示匹配的结果,如下所示:

Live Search 插件与 Filter 插件对比

每个表的特定 GridPanel

在我们实现控制器之前,我们的最后一站是特定的 GridPanel。我们已经创建了一个包含我们所需大多数功能的超级 GridPanel。现在我们只需要为每个 GridPanel 声明特定的配置。

我们将创建五个 GridPanel,它们将扩展自 Packt.view.staticData.BaseGrid 类,如下所示:

  • Packt.view.staticData.Actors

  • Packt.view.staticData.Categories

  • Packt.view.staticData.Languages

  • Packt.view.staticData.Cities

  • Packt.view.staticData.Countries

让我们从 Actors GridPanel 开始,如下所示:

Ext.define('Packt.view.staticData.Actors', {
    extend: 'Packt.view.staticData.BaseGrid',
    xtype: 'actorsgrid',        //#1

    store: 'staticData.Actors', //#2

    columns: [
        {
            text: 'Actor Id',
            width: 100,
            dataIndex: 'actor_id',
            filter: {
                type: 'numeric'   //#3
            }
        },
        {
            text: 'First Name',
            flex: 1,
            dataIndex: 'first_name',
            editor: {
                allowBlank: false, //#4
                maxLength: 45      //#5
            },
            filter: {
                type: 'string'     //#6
            }
        },
        {
            text: 'Last Name',
            width: 200,
            dataIndex: 'last_name',
            editor: {
                allowBlank: false, //#7
                maxLength: 45      //#8
            },
            filter: {
                type: 'string'     //#9
            }
        }
    ]
});

每个特定的类都有自己的 xtype (#1)。我们还需要在数据库中执行一个 UPDATE 查询来更新菜单表,以包含我们正在创建的新 xtypes:

UPDATE `sakila`.`menu` SET `className`='actorsgrid' WHERE `id`='5';
UPDATE `sakila`.`menu` SET `className`='categoriesgrid' WHERE `id`='6';
UPDATE `sakila`.`menu` SET `className`='languagesgrid' WHERE `id`='7';
UPDATE `sakila`.`menu` SET `className`='citiesgrid' WHERE `id`='8';
UPDATE `sakila`.`menu` SET `className`='countriesgrid' WHERE `id`='9';

针对 Actors GridPanel 特定的第一个声明是 Store (#2)。我们将使用 Actors Store。因为 Actors Store 在 staticData 文件夹内(store/staticData),我们还需要传递子文件夹的名称;否则,Ext JS 会认为此 Store 文件在 app/store 文件夹内,这并不正确。

然后我们需要声明 Actors GridPanel 特定的 columns(我们不需要声明 Last UpdateDelete 操作列,因为它们已经在超级 GridPanel 中了)。

现在您需要关注的是每列的 editorfilter 配置。editor 用于编辑(cellediting 插件)。我们只会将此配置应用到我们希望用户能够编辑的列上,而 filter (filters 插件)则是我们将应用到用户希望从 columns 中过滤信息的配置。

例如,对于 id 列,我们不想让用户能够编辑它,因为它是由 MySQL 数据库自动递增提供的序列,因此我们不会对此列应用 editor 配置。然而,用户可以根据 ID 过滤信息,因此我们将应用 filter 配置(#3)。

我们希望用户能够编辑其他两列:first_namelast_name,因此我们将添加 editor 配置。我们可以像在表单字段上执行客户端验证一样执行客户端验证。例如,我们希望这两个字段都是必填的(#4#7),用户可以输入的最大字符数是 45#5#8)。

最后,由于这两个列都是渲染文本值(string),我们也将应用 filter#6#9)。

对于其他过滤类型,请参考以下截图所示的 Ext JS 文档。文档提供了一个示例和更多可用的配置选项:

每个表格的特定 GridPanels

就这样!超级 GridPanel 将提供所有其他功能。

添加 Live Search CSS

当我们导航到 Live Search Grid 示例(dev.sencha.com/ext/5.0.1/examples/grid/live-search-grid.html)并查看其源代码时,我们可以看到示例导入了两个 CSS 文件:LiveSearchGridPanel.cssstatusbar.css(因为 Live Search 插件依赖于 statusbar 插件)。我们还需要将此 CSS 添加到我们的应用程序中。

我们将复制这两个 CSS 文件并将扩展名更改为 scss。Live Search CSS 可以从 ext/src/ux/css 复制,而 statusbar CSS 可以从 ext/src/ux/statusbar 复制。我们需要将这些文件放置在我们的应用程序自定义 CSS 中,该 CSS 位于 sass/etc 目录。创建一个名为 ux 的新文件夹并将这些文件粘贴进去。在 all.scss 中,我们将导入这两个 Sass 文件:

@import "ux/statusbar";
@import "ux/LiveSearchGridPanel";

statusbar插件显示一个图标,我们还需要修复statusbar图像的路径。在statusbar.scss文件中,将所有匹配的"../images"替换为"images/statusbar"。转到ext/src/ux/statusbar,复制images文件夹,并将其粘贴到resources/images中。将resources/images/images重命名为resources/images/statusbar。如果你在终端中执行Sencha app watch,Sencha Cmd 将重新构建应用程序 CSS 文件,并且插件将 100%正常工作。

适用于所有表格的通用控制器

现在是时候实现静态数据模块的最后一块了。目标是实现一个控制器,它拥有最通用的代码,将为所有屏幕提供功能,而无需我们为任何屏幕创建任何特定方法。

让我们从控制器的基类开始。我们将创建一个名为Packt.controller.StaticData的新类,如下所示:

Ext.define('Packt.controller.StaticData', {
    extend: 'Ext.app.Controller',

    requires: [
        'Packt.util.Util', //#1
        'Packt.util.Glyphs'
    ],

    stores: [  //#2
        'staticData.Actors',
        'staticData.Categories',
        'staticData.Cities',
        'staticData.Countries',
        'staticData.Languages'
    ],

    views: [ //#3
        'staticData.BaseGrid',
        'staticData.Actors',
        'staticData.Categories',
        'staticData.Cities',
        'staticData.Countries',
        'staticData.Languages'
    ],

    init: function(application) {
        var me = this;
        me.control({
            //event listeners here
        });   
    }
});

现在,我们将声明requires#1)——我们将在某些方法中使用Util类和Glyphs类),stores#2)——我们可以在这里列出这个模块的所有存储),以及views#3)——我们可以在这里列出这个模块的所有视图)。

由于我们通过xtype实例化视图,我们需要在某个地方声明它们(#2)。这可以在Application.js文件中的requires配置内或在一个控制器内完成。这是必需的,因为 Ext JS 不知道我们创建的 xtypes,所以类的名称需要列在某个地方。

我们在这个控制器中列出的stores声明(#2)将在控制器实例化时一起实例化。由于这是一个 MVC 架构中的控制器,其作用域是全局的,它将在应用程序启动时创建。对于这种情况使用 MVC 方法是有趣的;毕竟,我们正在构建一个静态数据模块,该模块引用了应用程序其他实体使用的通用数据。在这种情况下,这些信息将在应用程序的生命周期中始终是活动的。这与在 ViewModel 内部声明的 Views、ViewModels、ViewControllers 和存储不同,它们仅在视图活动时(标签页打开)存在。由于我们在Base存储中设置了autoLoad:true,当应用程序启动时,在这个控制器中列出的存储将被实例化和加载。

我们还有init函数和this.control,我们将在这里监听所有我们感兴趣的事件。

找到正确的选择器

在每个静态数据网格面板的工具栏上,我们有一个上面写着添加的按钮。当我们点击这个按钮时,我们希望将一个新的模型条目添加到存储中(并且相应地,在网格面板上添加一条新记录),并启用编辑功能,以便用户可以填写值以便稍后保存(当他们点击保存更改按钮时)。

当在控制器中监听事件时,首先我们需要传递一个选择器,这个选择器将由Ext.ComponentQuery类使用来查找组件。然后我们需要列出我们想要监听的事件。接下来,我们需要声明当监听的事件被触发时要执行的功能,或者声明当事件被触发时要执行的控制器的名称。在我们的例子中,我们只是为了代码组织的目的声明方法。

现在,让我们专注于找到添加按钮的正确选择器(其他按钮也将类似)。根据Ext.ComponentQuery API 文档,我们可以通过它们的xtype来检索组件(如果你已经熟悉 JQuery,你会注意到Ext.ComponentQuery选择器的行为与 JQuery 选择器的行为非常相似)。嗯,我们正在尝试检索两个按钮,它们的xtypebutton。然后我们可以尝试选择器'button'。但在我们开始编码之前,让我们确保这是一个正确的选择器,以避免不断更改代码来尝试找出正确的选择器。有一个非常有用的技巧我们可以尝试:打开浏览器控制台——命令编辑器——并输入以下命令并点击运行:

Ext.ComponentQuery.query('button');

如以下截图所示,它返回了我们使用选择器找到的按钮数组,并且数组包含多个按钮!太多的按钮不是我们想要的。我们想要缩小到演员屏幕中的添加按钮,如下面的截图所示:

寻找正确的选择器

让我们尝试使用我们使用的xtype组件绘制演员屏幕的路径。我们有演员屏幕(xtype: actorsgrid);在屏幕内部,我们有一个工具栏(xtype: toolbar);在工具栏内部,我们有一些按钮(xtype: button)。因此,我们有actorsgrid | toolbar | button。所以我们可以尝试以下命令:

Ext.ComponentQuery.query('actorsgrid toolbar button');

所以让我们在控制台上尝试这个最后的选择器,如下所示:

寻找正确的选择器

现在的结果是一个包含六个按钮的数组,这些就是我们正在寻找的按钮!还有一个细节缺失:如果我们使用'actorsgrid toolbar button'选择器,它将监听所有六个按钮的点击事件(这是我们想要监听的事件)。

然而,当我们点击取消按钮时,应该发生一个动作;当我们点击保存按钮时,应该发生不同的动作,因为它是不同的按钮。所以我们仍然想要进一步缩小选择器,直到它返回我们正在寻找的添加按钮。

回到 Base Grid 面板代码,注意我们为所有按钮声明了一个名为itemId的配置。我们可以使用这些itemId配置以独特的方式识别按钮。根据Ext.ComponentQuery API 文档,我们可以使用#作为itemId的前缀。所以让我们在控制台上尝试以下命令来获取添加按钮的引用:

Ext.ComponentQuery.query('actorsgrid toolbar button#add');

并且输出结果将只有一个按钮,正如我们所期望的:

查找正确的选择器

所以现在我们得到了我们一直在寻找的选择器!使用控制台是一个非常好的工具,当尝试找到我们想要的精确选择器而不是编码、测试、没有得到我们想要的选择器、再次编码、再次测试等等时,它可以节省我们很多时间。

注意

我们能否只用button#add作为选择器?是的,我们可以使用一个更短的选择器。然而,现在它将完美地工作。随着应用的成长和更多类和按钮的声明,事件将会对所有具有itemId值为add的按钮触发,这可能会导致应用出错。我们始终需要记住itemId是局部作用域的,使用actorsgrid toolbar buttonactorsgrid button作为选择器,我们确保事件将来自演员屏幕的按钮。

现在是时候关注最后一个细节了。我们找到的选择器仅适用于演员屏幕。我们希望有一些通用的代码——可以在静态数据模块的所有屏幕上使用的代码。好消息是,我们创建了一个超级类(Packt.view.staticData.BaseGrid),其xtypestaticdatagrid

使用 itemId 与 id 的比较 – Ext.Cmp 很糟糕!

每当我们能这样做的时候,我们总是会尝试使用itemId配置而不是id来唯一标识一个组件。那么问题来了:为什么?

当使用id时,我们需要确保id是唯一的,并且应用中的所有其他组件都不能有相同的id属性。现在,想象一下你和其他开发者在同一个团队中工作,并且这是一个大型应用的情况。你怎么能确保id将是唯一的呢?这将会非常困难。你不这么认为吗?这可能会是一个难以完成的任务。

使用id创建的组件可以通过Ext.getCmp全局访问,这是Ext.ComponentManager.get的简写。

只举一个例子,当使用Ext.getCmp通过id检索一个组件时,它将返回具有给定id的最后一个声明的组件。如果id不是唯一的,它可能会返回你意想不到的组件,这可能导致应用中出错,如下面的图所示:

使用 itemId 与 id 的比较 – Ext.Cmp 很糟糕!

不要慌张!有一个优雅的解决方案:使用itemId而不是id

itemId 可以用作获取组件引用的另一种方式。itemId 是容器内部 MixedCollection 的索引,这就是为什么 itemId 是在容器范围内局部化的。这是 itemId 的最大优势。

例如,我们可以有一个名为 MyWindow1 的类,它继承自 Window,在这个类中,我们可以有一个 itemId 包含 submit 值的按钮。然后,我们可以有一个名为 MyWindow2 的另一个类,它也继承自 Window,在这个类中,我们也可以有一个 itemIdsubmit 的按钮。

有两个 itemId 具有相同值的情况并不成问题。我们只需要在使用 Ext.ComponentQuery 检索我们想要的组件时小心。例如,如果我们有一个别名是 login登录 窗口,另一个名为 注册 的屏幕,其窗口别名是 registration,并且两个窗口都有一个名为 保存 的按钮,其 itemIdsave,如果我们简单地使用 Ext.ComponentQuery.query('button#save'),结果将是一个包含两个结果的数组。然而,如果我们进一步缩小选择器——比如说我们想要的是 登录保存 按钮,而不是 注册保存 按钮,我们需要使用 Ext.ComponentQuery.query('login button#save'),结果将只有一个,这正是我们期望的。本段内容的适当封装如下图所示:

使用 itemId 与 id 的比较 – Ext.Cmp 是不好的!

你会注意到,在我们的项目代码中不会使用 Ext.getCmp,因为这并不是一个好的实践,原因如前所述。在 Ext JS 3 之前,这是我们必须使用的方式来检索组件。但从 Ext JS 4 开始,随着 MVC 架构和 MVVM 的引入,这已经不再需要了。

在 GridPanel 上添加新记录

既然我们已经知道了如何在 Controller 中找到正确的选择器,让我们继续声明 添加 按钮的选择器:

'staticdatagrid button#add': {
    click: me.onButtonClickAdd
}

然后,我们需要实现 onButtonClickAdd 方法:

onButtonClickAdd: function (button, e, options) {
    var grid = button.up('staticdatagrid'), //#1
        store = grid.getStore(),            //#2
        modelName = store.getModel().getName(), //#3
        cellEditing = grid.getPlugin('cellplugin');  //#4

    store.insert(0, Ext.create(modelName, { //#5
        last_update: new Date()             //#6
    }));

    cellEditing.startEditByPosition({row: 0, column: 1}); //#7
}

从参数中,我们只有 button 引用。我们需要获取 GridPanel 的引用。因此,我们将使用 up 方法来获取它(#1)。我们再次将超级 GridPanel 的 xtype 作为选择器(staticdatagrid),因为这样我们就有最通用的代码。

Ext JS 中的所有组件都有查询其他组件的方法。它们如下列出:

  • up:这个方法沿着所有者层次结构向上导航,寻找匹配任何传递的选择器或组件的祖先容器

  • down:这个方法检索匹配传递的选择器的第一个后代组件

  • query:这个方法检索所有匹配传递的选择器的后代组件

方法 query 也有一些替代方案,例如 queryBy(function)queryById

一旦我们有了 GridPanel 的引用,我们可以使用 getStore 方法(#2)来获取 Store 的引用。

我们需要模型名称来实例化它(#5),这样我们就可以在 Store 的第一个位置插入(这样它将是 GridPanel 的第一行)。所以,仍然针对通用代码,我们可以从store#3)中获取modelName

在实例化模型时,我们可以传递一些配置。我们希望最后更新列也被更新;我们将只传递最新的日期时间作为配置。

最后,我们还想让用户注意到行的某个单元格可以编辑,所以我们将焦点放在网格的第二列(第一列是id,不可编辑)的第一行(#7)。但要做到这一点,我们需要celleditor插件的引用;我们可以通过使用getPlugin方法,传递pluginId作为参数(#4)来获取它。

只是为了提醒,我们在Packt.view.staticData.BaseGrid类的cellediting插件中声明了pluginId,如下面的代码片段所示:

{
    ptype: 'cellediting',
    clicksToEdit: 1,
 pluginId: 'cellplugin'
}

注意

注意,仅使用一个方法,我们就可以编程必要的逻辑。代码是通用的,并为所有静态数据网格面板提供相同的功能。

编辑现有记录

单元格的编辑将由cellediting插件自动完成。然而,当用户点击单元格进行编辑并完成编辑后,我们需要将最后更新值更新为当前的日期时间

cellediting插件有一个名为edit的事件,允许我们监听我们想要的事件。不幸的是,控制器无法监听插件事件。幸运的是,GridPanel 类也会触发此事件(cellediting插件将事件内部转发到GridPanel),因此我们可以监听它。所以我们将添加以下代码到控制器的init控制中:

"staticdatagrid": {
edit: me.onEdit
}

接下来,我们需要实现onEdit方法,如下所示:

onEdit: function(editor, context, options) {
    context.record.set('last_update', new Date());
}

第二个参数是事件(上下文)。从这个参数中,我们可以获取用户编辑的模型实例(record)并将last_update字段设置为当前的日期时间

在控制器中删除处理 Widget 列

好吧,读取、创建和更新操作已经实现。现在,我们需要实现删除操作。我们没有删除操作的按钮,但我们确实有一个动作列的item

在主题处理 MVC 架构中的 Widget 列中,你学习了如何从动作列项触发事件,以便我们可以在控制器中处理它。我们无法监听动作列本身触发的事件;然而,我们可以监听我们创建的动作列触发的事件:

"staticdatagrid actioncolumn": {
    itemclick: this.handleActionColumn
}

现在,让我们看看如何实现handleActionColumn方法:

handleActionColumn: function(column, action, view, rowIndex, colIndex, item, e) {
        var store = view.up('staticdatagrid').getStore(),
        rec = store.getAt(rowIndex);

        if (action == 'delete'){
            store.remove(rec);
            Ext.Msg.alert('Delete', 'Save the changes to persist the removed record.');
        }   
    }

由于这是一个自定义事件,我们需要获取动作列项传递的参数。

因此,首先,我们需要获取 Store 以及用户点击删除的record。然后,使用第二个参数,即 Action 列项的action名称,我们有一种方式知道哪个项触发了事件。所以如果action值是delete,我们将从 Store 中删除record并要求用户通过按下按钮保存更改来提交更改,这将同步 Store 中的模型与服务器上的信息。

保存更改

用户执行更新、删除或创建操作后,更新的单元格将有一个标记(脏标记,以便 Store 知道哪些模型已被修改),如下面的截图所示:

保存更改

与我们可以在 MySQL 表中执行更改的方式相同,我们需要保存更改(提交)。这就是为什么我们创建了保存更改按钮;这样,我们将一次性将所有更改同步到服务器。

首先,我们需要向me.control添加一个监听器,如下所示:

'staticdatagrid button#save': {
    click: me.onButtonClickSave
}

然后,我们需要实现onButtonClickSave方法:

onButtonClickSave: function (button, e, options) {
    var grid = button.up('staticdatagrid'), //#1
        store = grid.getStore(),            //#2
        errors = grid.validate();           //#3

    if (errors === undefined){  //#4
        store.sync();           //#5
    } else {
        Ext.Msg.alert(errors);  //#6
    }
}

方法的实现相当简单:我们只需要从 GridPanel(#1)获取 Store(#2)并调用sync方法(#5)。

然而,我们将验证在 Grid 的单元格中输入的信息是否包含有效信息。为此,我们将从网格(#3)调用validate方法并获取errors。如果没有发现错误(#4),则将 Store 与服务器同步(#5);否则,显示errors#6)。

Grid Panel 默认没有验证方法。我们将向Base Grid Panel 添加此方法。

验证 GridPanel 中的单元格编辑

回到类Packt.view.staticData.BaseGrid,我们将添加验证行和整个网格在保存之前的逻辑。在这个例子中,你将了解到我们也可以向创建的类中添加有用的方法;我们不需要在 Controller 中开发所有代码。

我们将要实现的第一种方法是validateRow。给定一个记录,我们将使用模型验证器来验证它,如果有任何错误,我们将在包含错误的单元格中添加 Ext JS 在表单中显示的相同表单图标(在单元格验证错误的情况下),并且我们还将在此单元格中添加一个工具提示(与表单验证类似的行为)。该方法的代码如下(我们需要将其添加到initComponent方法中):

me.validateRow = function(record, rowIndex){

    var me = this,
        view = me.getView(),
        errors = record.validate(); //#1

    if (errors.isValid()) {         //#2
        return true;
    }

    var columnIndexes = me.getColumnIndexes(); //#3

    Ext.each(columnIndexes, function (columnIndex, col) { //#4
        var cellErrors, cell, messages;

        cellErrors = errors.getByField(columnIndex);      //#5
        if (!Ext.isEmpty(cellErrors)) {
            cell = view.getCellByPosition({
            row: rowIndex, column: col
   });
            messages = [];
            Ext.each(cellErrors, function (cellError) { //#6
                messages.push(cellError.message);
            });

            cell.addCls('x-form-error-msg x-form-invalid-icon x-form-invalid-icon-default'); //#7

            cell.set({ //#8
                'data-errorqtip': Ext.String.format('<ul><li class="last">{0}</li></ul>',
                    messages.join('<br/>'))
            });
        }
    });

    return false;
};

给定一个record模型(#1),我们将使用其validate方法。此方法验证模型中包含的信息是否根据模型验证器有效(我们需要回到本章定义的模型并添加验证器)。此方法返回一个包含记录中所有错误的对象。

使用isValid方法(#2),我们可以轻松地找出模型是否有效。如果它是有效的,我们返回true(我们将在以后使用这个信息)。

columnIndexes#3)在一个数组中,包含网格每一列的dataIndex。这个方法在 Ext JS 4.1 的网格面板类中存在,但在 Ext JS 4.2 中被移除,并且在 Ext JS 5 中不存在。我们将实现这个方法,但区别在于在这种情况下,我们只对启用了编辑器的列感兴趣(因为我们想对其进行验证)。

然后,对于网格中启用了编辑器的每一列(#4),我们将从模型中检索该列特定的errors#5)。我们将每个错误message#6)添加到一个消息数组中。之后,我们将从无效表单字段中添加相同的错误图标到网格单元格(#7)。最后,我们还将向有错误的单元格添加一个工具提示(#8)。

接下来,我们将实现getColumnIndexes函数,如下所示:

me.getColumnIndexes = function() {
    var me = this,
        columnIndexes = [];

    Ext.Array.each(me.columns, function (column) { //#9
        if (Ext.isDefined(column.getEditor())) {   //#10
            columnIndexes.push(column.dataIndex);  //#11
        } else {
            columnIndexes.push(undefined);
        }
    });

    return columnIndexes; //#12
};

对于网格的每一列(#9),我们将验证编辑器是否已启用(#10),如果是,我们将dataIndex添加到columnIndexes数组(#11)。最后,我们返回这个数组(#12)。

validateRow方法只适用于单行。当我们点击保存更改按钮时,我们希望验证整个网格。为此,我们将在initComponent内部实现一个额外的validate方法(我们在控制器中调用过该方法):

me.validate = function(){

    var me = this,
        isValid = true,
        view = me.getView(),
        error,
        record;

    Ext.each(view.getNodes(), function (row, col) { //#13
        record = view.getRecord(row);

        isValid = (me.validateRow(record, col) && isValid); //#14
    });

    error = isValid ? undefined : { //#15
        title: "Invalid Records",
        message: "Please fix errors before saving."
    };

    return error; //#16
};

对于validate方法,我们将从网格中检索所有行,并对每一行(#13)调用validateRow方法(#14)。在行#14中,我们还会跟踪是否有任何之前的行是有效的

最后,如果没有发现错误,我们返回undefined#16),或者返回一个包含标题和消息的对象(#15),我们可以在控制器中使用它来显示警告。

如果我们添加一个空行或编辑包含无效信息的单元格,网格将在单元格上显示一个错误图标,并且当我们将鼠标悬停在其上时也会显示一个工具提示。输出将类似于以下截图:

在 GridPanel 中验证单元格编辑

模型验证器

要使此代码工作,我们需要将模型验证器添加到staticData模型中。例如,对于Actors模型,我们可以根据actor表的数据库验证添加以下代码:

validators: {
        first_name: [
            { type: 'presence', message: 'This field is mandatory'},
            { type: 'length', min: 2, max: 45}
        ],
        last_name: [
            { type: 'presence', message: 'This field is mandatory'},
            { type: 'length', min: 2, max: 45}
        ]
    }

Packt.model.staticData.Base类中,我们可以添加一个last_update验证器,这个验证器将适用于所有staticData模型:

validators: {
    last_update: 'presence'
}

自动同步配置

Store 有一个名为autoSync的配置。默认值是false,但如果我们将其设置为true,Store 将在检测到更改时自动与服务器同步。这可能很好,但也可能很糟糕——这取决于我们如何使用它。

例如,如果我们没有验证,用户可以创建一个空行并尝试将其发送到服务器。服务器代码将失败(某些列不能为 null),这对应用程序来说很糟糕。将 autoSync 设置为 false,用户还可以选择何时保存信息,信息可以批量发送而不是每次操作(创建、更新或销毁)——allowSingle 也必须在代理写入器中设置为 false

取消更改

由于用户可以保存更改(提交),用户也可以取消它们(回滚)。你只需要重新加载 Store,以便从服务器获取最新信息,用户所做的更改将会丢失。

因此,我们需要监听以下事件:

staticdatagrid button#cancel"' {
    click: this.onButtonClickCancel
}

我们需要实现的方法:

onButtonClickCancel: function (button, e, options) {
button.up('staticdatagrid').getStore().reload();
}

如果你想,你可以添加一个消息询问用户是否真的想要回滚更改。从 Store 调用 reload 方法是我们需要做的来使其工作。

小贴士

作为 Store 的 reload 方法的替代,我们也可以调用 rejectChanges 方法;然而,reload 方法更安全,因为我们再次从服务器获取信息。

清除过滤器

当在 GridPanel 上使用 filter 插件时,它将执行我们需要的所有操作(当本地使用时)。但是,它没有提供给用户的一项功能:一次性清除所有过滤器的选项。这就是为什么我们实现了清除过滤器按钮。

因此,首先,让我们监听这个事件:

'staticdatagrid button#clearFilter {
    click: this.onButtonClickClearFilter
}

然后我们可以实现这个方法:

onButtonClickClearFilter: function (button, e, options) {
button.up('staticdatagrid').filters.clearFilters();
}

当使用 filter 插件时,我们能够从 GridPanel 获取一个名为 filters 的属性。然后,我们只需要调用 clearFilters 方法。这将清除每个被过滤的列的过滤器值,并也会从 Store 中清除过滤器。

在 Controller 中监听 Store 事件

我们将要监听的最后一个是 Store 的 write 事件。我们已经在 Proxy 中添加了一个 exception 监听器(在 Ext JS 3 中,Store 有一个异常监听器,而在 Ext JS 4 和 5 中,这个监听器被移动到了 Proxy)。现在我们需要添加一个监听器以处理成功的情况。

第一步是在 Controller 中监听 Store 的事件。这个功能是在 Ext JS 4.2.x 中引入的。

在 Controller 的 init 函数中,我们将添加以下代码:

me.listen({
    store: {
        '#staticData.Actors': {
            write: this.onStoreSync
        }
    }
});

我们可以在存储选项中监听 Store 事件。之前的代码将适用于演员屏幕。如果我们想对其他屏幕做同样的事情,我们需要添加与前面相同的代码。例如,以下代码需要添加到分类屏幕:

'#staticData.Categories': {
    write: this.onStoreSync
}

我们不能在这里使用 Base Store 的 storeId,因为每个子 Store 都将有自己的 storeId

当 Store 收到来自服务器的响应时,将触发写事件。所以让我们实现这个方法:

onStoreSync: function(store, operation, options){
Packt.util.Util.showToast('Success! Your changes have been saved.');
}

我们将简单地显示一条消息,说明更改已保存。请注意,该消息也是通用的;这样我们就可以为所有静态数据模块使用它。输出截图如下:

在控制器中监听 Store 事件

调试技巧 – Chrome 的 Sencha 扩展程序

有时我们需要检查我们实例化的特定 Ext JS 组件以进行调试。找到可用的方法和对象中设置的当前属性和配置可能有点棘手。在 MVC 工作时,我们可能需要找到创建的 Store ID 和应用程序中活跃的存储。使用 console.log 输出特定对象以进行检查是一项大量工作!

Sencha Labs 团队发布了一个名为 Sencha App Inspector 的免费 Chrome 扩展程序。当使用 Ext JS(或 Sencha Touch)时,建议您使用此扩展程序来帮助调试应用程序。在下面的屏幕截图中,我们可以看到扩展程序在运行——应用程序实例化的存储以及加载到其中的数据:

调试技巧 – Chrome 的 Sencha 扩展程序

注意

有关 Sencha App Inspector 的更多信息以及下载链接,请访问 github.com/senchalabs/AppInspector/

Firefox 扩展程序 – 开发者照明

如果您喜欢的开发浏览器不是 Chrome,还有适用于 Firefox(可在 Firebug 中使用)的 Sencha 扩展程序,名为开发者照明。

这不是一个免费扩展程序(但可以在有限的时间内免费试用),其成本具有很好的性价比。

在下面的屏幕截图中,我们可以看到扩展程序在运行(应用程序实例化的存储及其属性):

Firefox 扩展程序 – 开发者照明

注意

有关开发者照明以及下载链接的更多信息,请访问 www.illuminations-for-developers.com/

摘要

在本章中,我们介绍了如何实现与 MySQL 表编辑器非常相似的屏幕。本章中我们介绍的最重要概念是使用面向对象编程中的继承概念实现抽象类。我们习惯于在服务器端语言中使用这些概念,例如 PHP、Java、.NET 等。本章演示了在 Ext JS 端使用这些概念也同样重要;这样,我们可以重用大量代码,并且实现通用的代码,为多个屏幕提供相同的功能。

我们创建了一个基础模型、存储、视图和控制器。我们使用了以下插件:celleditor 用于 GridPanel 和 Live Search 网格,以及 filter 插件用于 GridPanel。你学习了如何使用 Store 的功能来执行 CRUD 操作。你还学习了如何在控制器上创建自定义事件和处理 Widget Column 事件。在本章中,我们还探索了许多 MVC 控制器的功能。

注意

提醒:你可以通过从本书或 GitHub 仓库 github.com/loiane/masteringextjs 下载代码包来获取本章(包含额外功能和服务器端代码)的完整源代码。

在下一章中,你将学习如何实现内容管理模块,这比本章中仅管理单个表要深入得多。我们将管理来自其他表(与应用程序的业务相关)的信息及其在数据库中的所有关系。

第八章:内容管理

在上一章中,我们开发了静态数据模块,该模块模拟了从数据库中编辑表格的过程。基本上,它是一个带有一些额外功能的单个表的创建、读取、更新、删除CRUD)操作。在本章中,我们将进一步探讨从表中管理信息的复杂性。通常,在现实世界的应用程序中,我们想要管理的表与其他表有关联,我们必须管理这些关联。这正是本章的主题。我们如何在 Ext JS 中构建屏幕并管理复杂信息?

因此,在本章中,我们将涵盖:

  • 使用 Ext JS 管理复杂信息

  • 如何处理多对多关联

  • 带关联的表单

  • 组件重用

管理信息 – 电影

Sakila 数据库内部有四个主要模块:库存,它包含了电影信息以及库存信息(每个店面有多少部电影可供出租);客户数据,它包含了客户信息;业务,它包含了店面、员工以及租赁和支付信息(这取决于库存和客户数据以提供一些信息);以及视图,它包含了我们可以用于报告和图表的数据。

目前,我们只对库存、客户数据和业务感兴趣,这些包含了应用程序的核心业务信息。让我们来看看库存,它比其他两个表有更多的表:

管理信息 – 电影

根据 Sakila 文档:

电影表是所有可能存放在店面中的电影的列表。每种电影的实际库存副本在库存表中表示。

电影表引用了语言表,并被电影类别电影演员库存表引用。

电影表与类别演员表之间存在多对多关系。它与语言表有两个多对一关系。在上一章中,我们已经开发了管理类别演员语言表的代码。现在,我们需要管理电影表与其他表之间的关系。

Ext JS 5 确实具有管理类似于电影表的关联实体的出色功能。我们将在本章深入探讨这些功能。

因此,让我们简要地看看本章将要开发的屏幕。

首先,我们需要一个屏幕来列出我们拥有的电影,如下所示:

管理信息 – 电影

这个屏幕显示了三个数据网格。第一个是film表,它将显示所有电影的列表。第二个是电影类别,它表示filmcategory表之间的多对多关系。第三个是电影演员,它表示filmactor表之间的多对多关系。

然后,如果我们想创建或编辑一个电影,我们将在窗口内创建一个表单面板,以便我们可以编辑其信息,如下所示:

管理信息 – 电影

由于film表与categories表有一个多对多关联,我们还需要在表单面板中使用不同的标签页来处理它。如果我们想添加更多与电影相关的类别,我们可以搜索并添加,如下所示:

管理信息 – 电影

同样,film表也与actor表有一个多对多关联,因此我们还需要在表单面板中处理它。以下截图展示了这一点:

管理信息 – 电影

如果我们想添加更多与电影相关的演员,我们可以使用搜索并添加演员,如下所示:

管理信息 – 电影

注意,我们对每个屏幕采取了不同的方法。这样我们可以学习更多在 Ext JS 中处理这些场景的方法。到本章结束时,我们将学会创建这个复杂的表单并保存相关数据。

因此,现在我们已经对在本章中将要实现的内容有了概念,让我们享受乐趣并动手实践吧!

显示电影数据网格

首先,让我们从基础知识开始。每次我们需要实现一个复杂的屏幕时,我们都需要从我们可以开发的 simplest component 开始。当这个组件工作后,我们可以开始逐步增加它并添加更复杂的功能。所以首先,我们需要创建一个模型来表示film表。在本章中,我们将使用 MVVM 方法,这样我们可以深入了解之前章节中没有涉及到的功能。一旦这部分代码工作正常,我们就可以处理categorylanguageactor表之间的关系。

电影模型

首先,我们将创建一个模型来表示film表。现在我们先不考虑这个表之间的关系。

我们需要创建一个名为Packt.view.film.FilmsGrid的新类,如下所示:

Ext.define('Packt.model.film.Film', {
    extend: 'Packt.model.staticData.Base', //#1

    entityName: 'Film',

    idProperty: 'film_id',

    fields: [
        { name: 'film_id' },
        { name: 'title'},
        { name: 'description'},
        { name: 'release_year', type: 'int'},
        { name: 'language_id'},
        { name: 'original_language_id'},
        { name: 'rental_duration', type: 'int'},
        { name: 'rental_rate', type: 'float'},
        { name: 'length', type: 'int'},
        { name: 'replacement_cost', type: 'float'},
        { name: 'rating'},
        { name: 'special_features'}
    ]
});

由于所有 Sakila 表都有last_update列,我们将扩展Packt.model.staticData.Base以避免在创建代表 Sakila 表的每个模型时声明此字段。staticData.Base类也扩展了Packt.model.Base,它包含我们模型的schemaproxy细节。

对于字段,我们将与film表中的字段相同。

电影模型视图

我们下一步是创建一个 ModelView,它将包含一个 Store,该 Store 将加载影片集合。让我们在 ViewModel 中创建一个名为 films 的 Store(记住,Store 的名称总是 Model 名称的复数形式——如果你想要遵循 Sencha 命名约定),如下所示:

Ext.define('Packt.view.film.FilmsModel', {
    extend: 'Ext.app.ViewModel',

    alias: 'viewmodel.films',

    stores: {
        films: {
            model: 'Packt.model.film.Film', //#1
            pageSize: 15,   //#2
            autoLoad: true, //#3
            session: true   //#4
        }
    }
});

我们需要在 app/view/film 文件夹内创建一个名为 FilmsModel.js 的文件,并将前面的代码放入该文件中。

films Store 中,我们像往常一样声明 model (#1),并且我们还声明 pageSize15 (#2),这意味着我们将使用 Films 数据网格中的分页工具栏,并且我们将每单位时间检索 15 部影片的集合来显示在 GridPanel 中。

autoLoad 配置也被设置为 true (#3)。由于 ViewModel 在 View 实例化后创建,因此 Store 也会在 View 创建时加载。

最后,我们有一个 session 配置 (#4)。到目前为止,我们在这本书中还没有使用会话。当我们上一章中处理 CRUD 时,我们使用了 Store 来保存数据。在这一章中,我们将处理关联数据,并且当我们需要从不同的模型中保存数据时,会话可以非常有帮助。会话将在 View 中创建。在 ViewModel 中的 Store 内声明 session: true 将 Store 与 View 的会话链接起来。我们将在本章的后面讨论它是如何工作的。

影片数据网格(带分页)

现在我们已经有了 Model 和带有 Store 的 ViewModel,我们需要创建 FilmsGrid,我们可以按照以下方式操作:

Ext.define('Packt.view.film.FilmsGrid', {
    extend: 'Packt.view.base.Grid', //#1
    xtype: 'films-grid',

    bind : '{films}',  //#2

    reference: 'filmsGrid', //#3

    columns: [{
        text: 'Film Id',
        width: 80,
        dataIndex: 'film_id'
    },{
        text: 'Title',
        flex: 1,
        dataIndex: 'title',
     renderer: function(value, metaData, record ){  //#4
            metaData['tdAttr'] = 'data-qtip="' +
                    record.get('description') + '"'; //#5
            return value;
        }
    },{
        text: 'Language',
        width: 100,
        dataIndex: 'language_id'
    },{
        text: 'Release Year',
        width: 110,
        dataIndex: 'release_year'
    },{
        text: 'Length',
        width: 100,
        dataIndex: 'length',
        renderer: function(value, metaData, record ){ //#6
            return value + ' min';
        }
    },{
        text: 'Rating',
        width: 70,
        dataIndex: 'rating'
    }]
});

随着我们的应用程序开始增长,我们注意到我们在不同的组件中使用了某些配置。例如,对于大多数的 GridPanels,我们使用带有添加编辑删除按钮的工具栏,或者我们可以使用带有添加按钮的工具栏,并将编辑删除按钮放在网格内的 Widget Columns(或 Action Columns)中。由于 Sakila 数据库中的所有表都有最后更新列,因此这个列也是我们用于列出 Sakila 表信息的所有 GridPanels 的共同点。因此,我们可以创建一个超级 GridPanel(就像我们为静态数据模块专门创建的那样)。所以,对于影片 GridPanel,我们将从我们将要创建的 base.Grid (#1) 扩展。

已经声明了 ViewModel 后,我们也可以在这个网格中将 films Store 进行绑定 (#2)。为了使我们的工作更简单,我们还将声明这个网格的 reference (#3)。

然后,我们有列映射 dataIndex 与 Film 模型的字段。当我们想要操作将在网格中显示的信息时,我们可以使用 renderer 函数。对于长度列,我们想要显示长度和 'min',因为影片的长度是以分钟为单位的。因此,我们可以返回值本身(长度)与我们要连接的字符串(#6)。

在渲染函数内部,我们还可以使用其他字段通过从record检索所需的字段来操作信息。例如,在标题列的渲染函数(#4)中,当用户将鼠标悬停在标题列上时,我们想要显示一个包含电影description#5)的工具提示。但我们没有改变将要显示的值,即title(渲染函数的value参数)本身。

创建基础网格

要创建基本的Grid类,我们将在app/view内部创建一个名为base的新基本文件夹,以便我们可以放置所有的base类。然后我们将创建一个名为Grid.js的新文件,内容如下:

Ext.define('Packt.view.base.Grid', {
    extend: 'Ext.grid.Panel',

    requires: [
        'Packt.util.Glyphs'
    ],

    columnLines: true,
    viewConfig: {
        stripeRows: true
    },

    initComponent: function() {
        var me = this;

        me.columns = Ext.Array.merge(
            me.columns,
            [{
                xtype    : 'datecolumn',
                text     : 'Last Update',
                width    : 150,
                dataIndex: 'last_update',
                format: 'Y-m-j H:i:s',
                filter: true
            },{
                xtype: 'widgetcolumn',
                width: 50,
                sortable: false,
                menuDisabled: true,
                widget: {
                    xtype: 'button',
                    glyph: Packt.util.Glyphs.getGlyph('edit'),
                    tooltip: 'Edit',
 handler: 'onEdit'   //#1
                }
            },{
                xtype: 'widgetcolumn',
                width: 50,
                sortable: false,
                menuDisabled: true,
                widget: {
                    xtype: 'button',
                    glyph: Packt.util.Glyphs.getGlyph('destroy'),
                    tooltip: 'Delete',
 handler: 'onDelete'  //#2
                }
            }]
        );

        me.callParent(arguments);
    }
});

我们在第七章中创建了一个与此类似的类。然而,这个类有一些不同。在#1#2行中,我们声明了一个将在 ViewController 中处理的handler。使用 MVVM 和 Widget 列工作比使用 MVC 方法简单得多,因为我们不需要触发自定义事件;我们可以在 ViewController 内部简单地声明onEditonDelete方法。

添加 RowExpander 插件

让我们回到FilmsGrid类并添加RowExpander插件。film表中的列比我们在FilmsGrid类内部显示的列要多。我们可以使用RowExpander插件来显示其他信息。

我们将在FilmsGrid类内部添加以下代码,如下所示:

plugins: [{
    ptype: 'rowexpander',
    rowBodyTpl: [
        '<b>Description:</b> {description}</br>',
        '<b>Special Features:</b> {special_features}</br>',
        '<b>Rental Duration:</b> {rental_duration}</br>',
        '<b>Rental Rate:</b> {rental_rate}</br>',
        '<b>Replacement Cost:</b> {replacement_cost}</br>'
    ]
}]

我们需要配置一个模板来显示我们想要显示的额外信息。在这种情况下,我们显示电影的description和一些无法适应列的其他信息,例如rental信息。

注意

如需了解有关模板的更多信息,请访问docs.sencha.com/extjs/5.0/5.0.0-apidocs/#!/api/Ext.Templatedocs.sencha.com/extjs/5.0/5.0.0-apidocs/#!/api/Ext.XTemplate

不幸的是,无法使用RowExpander插件与关联模型一起使用。如果我们想显示关联数据,我们可以使用SubTable插件。同时使用RowExpanderSubTable插件也是不可能的。

使用前面的代码,网格中将添加一个新列,以便我们可以看到这些额外信息:

添加 RowExpander 插件

Actor-Language – 处理 hasOne 关联

在第六章中,我们通过在User模型中添加新字段(创建User模型部分)使用hasOne关联处理了UserGroup模型之间的关系。在本章中,我们将学习一种不同的方法来在网格中显示hasOne关联。

一部电影与语言有一个 hasOne 关联(语言与电影有一个 hasMany 关联)。我们将使用以下代码中的 renderer 函数显示语言 name 而不是 language_id

dataIndex: 'language_id',
renderer: function(value, metaData, record ){
   var languagesStore = Ext.getStore('staticData.Languages'); //#1
   var lang = languagesStore.findRecord('language_id', value);//#2
   return lang != null ? lang.get('name') : value;            //#3
} 

我们将利用 Languages Store 是在应用程序的全局范围内创建的事实(我们在 第七章,静态数据管理 中创建的)并使用它。这样,我们就不需要再次从服务器加载语言 name。因此,我们将使用存储管理器检索 Store (#1) 并搜索具有 language_idLanguage 模型,这是我们正在寻找的 (#2)。如果存在该值,则显示它;否则,无论如何都显示 language_id 参数 (#3)。

尽管 Ext JS 有从服务器加载信息并使用关联功能解析的能力,但在这种情况下使用它是否值得,因为我们已经有一个包含我们需要的值的 Store 加载了?如果我们使用关联,这意味着将从服务器加载更多数据,其中一些数据对于不同的模型(在这种情况下,所有电影都有 language_id1,即英语)可能是重复的。因此,相同的语言模型将被加载多次,我们从服务器加载的 JSON 也会更大。

添加分页工具栏

接下来,我们将声明一个分页工具栏。在 FilmsGrid 类内部,我们将添加以下代码:

dockedItems: [{
    dock: 'bottom',
    xtype: 'pagingtoolbar',
    bind : {
        store: '{films}' //#1
    },
    displayInfo: true,
    displayMsg: 'Displaying films {0} - {1} of {2}',
    emptyMsg: "No films to display"
}]

分页工具栏是一个特殊的工具栏,它与 Store 相绑定。因此,我们需要指定 Store (#1)。在这种情况下,它将是我们在 FilmsGrid 中声明的同一个 Store。

处理服务器端分页

由于我们正在使用分页工具栏,因此记住以下几点很重要。Ext JS 提供了帮助我们分页内容的工具,但让我们强调一下“提供”这个词。如果一次从数据库中检索所有记录,Ext JS 不会为我们进行分页。

注意

如果我们想要分页已加载的数据,我们可以使用 Ext JS SDK 内提供的 PagingMemoryProxy (Ext.ux.data.PagingMemoryProxy)。

如果我们查看 Ext JS 发送到服务器的请求,我们会发现当我们使用分页工具栏时,它会发送三个额外的参数。这些参数是 startlimitpage。例如,正如我们所看到的,当我们第一次加载 GridPanel 信息时,start0limit 是我们在 Store 上设置的 pageSize 配置(在这种情况下,15),而 page1。以下图示了这一点:

处理服务器端分页

当我们点击 GridPanel 的下一页时,start 将会是 15(0 + limit(15)= 15),limit 将保持 15(除非我们动态更改 pageSize,否则此值不会改变),而 page 将是 2。这可以通过以下图示来演示:

处理服务器端分页

注意

有一个第三方插件可以根据用户的选项动态更改 pageSize,请参阅 github.com/loiane/extjs4-ux-paging-toolbar-resizer

这些参数帮助我们分页数据库上的信息。例如,对于 MySQL,我们只需要 startlimit,因此我们需要从请求中获取它们,如下所示:

$start = $_REQUEST['start'];
$limit = $_REQUEST['limit'];

然后,当我们执行 SELECT 查询时,我们需要在末尾添加 LIMIT $start, $limit(在 WHEREORDER BYGROUP BY 子句之后,如果有这些子句):

$sql = "SELECT * FROM film LIMIT $start,  $limit";

这将从数据库中获取我们所需的信息。

另一个非常重要的细节是,分页工具栏显示了我们在数据库上拥有的总记录数:

$sql = "SELECT count(*) as num FROM film";

因此,我们还需要在 JSON 中返回一个 total 属性,包含表的计数:

echo json_encode(array(
  "success" => $mysqli->connect_errno == 0,
  "data" => $result,
  "total" => $total
));

然后 Ext JS 将接收到所有必需的信息,以便按预期工作分页。

MySQL、Oracle 和 Microsoft SQL Server 的分页查询

我们需要小心,因为如果我们使用不同的数据库,直接从数据库分页查询信息的方式就不同。

如果我们使用的是 Oracle 数据库,带有分页的 SELECT 查询如下所示:

SELECT * FROM
  (select rownum as rn, f.* from
    (select * from film order by film_id) as f
  ) WHERE rn > $start  and rn <= ($start + $limit)

这将比 MySQL 复杂得多。现在让我们看看 Microsoft SQL Server(SQL Server 2012):

SELECT  *
FROM ( SELECT ROW_NUMBER() OVER ( ORDER BY film_id ) AS RowNum, *
          FROM films
        ) AS RowConstrainedResult
WHERE   RowNum > $start
    AND RowNum <= ($start + $limit)
ORDER BY RowNum

在 SQL Server 2012,这要简单得多:

SELECT * FROM film
ORDER BY film_id
OFFSET $start ROWS
FETCH NEXT $limit ROWS ONLY

在 Firebird,这比 MySQL 简单:

SELECT FIRST $limit SKIP $start * FROM film

所以,如果你使用的是不同于 MySQL 的数据库,请小心 SQL 语法。

创建电影容器

下一步是创建我们在本章开头提到的 Films 屏幕。它由一个带有按钮(添加)的工具栏、电影网格和两个相关网格(类别和演员)组成。我们将在这个 Films.js 文件中创建这个视图,如下所示:

Ext.define('Packt.view.film.Films', {
    extend: 'Ext.panel.Panel',
    xtype: 'films',

    requires: [
        'Packt.view.base.TopToolBar',
        'Packt.view.film.FilmsGrid',
        'Packt.view.film.FilmActorsGrid',
        'Packt.view.film.FilmCategoriesGrid',
        'Packt.view.film.FilmsModel',
        'Packt.view.film.FilmsController'
    ],

    controller: 'films', //#1
    viewModel: {
        type: 'films'    //#2
    },

    session: true,       //#3

    layout: {
        type: 'vbox',
        align: 'stretch'
    },

    items: [{
        xtype: 'films-grid',  //#4
        flex: 1
    },{
        xtype: 'container',
        split: true,
        layout: {
            type: 'hbox',
            align: 'stretch'
        },
        height: 150,
        items: [{
            xtype: 'film-categories', //#5
            flex: 1
        },{
            xtype: 'film-actors',    //#6
            flex: 2
        }]
    }],

    dockedItems: [{
        xtype: 'top-tool-bar'    //#7
    }]
});

在这个类中,我们声明了一个 ViewController (#1) 和 ViewModel (#2)。ViewModel 已经创建,因此我们需要创建 ViewController。

接下来,我们有 session (#3)。如果提供,将为该组件创建一个新的会话实例。由于这个类是其他类的容器,因此会话将被所有子组件继承。当我们工作在 ViewController 时,我们将深入研究会话。

在第 #4 行,我们有我们创建的 FilmsGrid 类。在第 #5#6 行,我们有 categoriesactors 网格,我们将使用它们来显示多对多关联。

我们还在第 #7 行声明了 TopToolBar。这个工具栏是单独创建的,因此我们可以重用它,如下所示:

Ext.define('Packt.view.base.TopToolBar', {
    extend: 'Ext.toolbar.Toolbar',
    xtype: 'top-tool-bar',

    requires: [
        'Packt.util.Glyphs'
    ],

    dock: 'top',
    items: [
        {
            xtype: 'button',
            text: 'Add',
            itemId: 'add',
            glyph: Packt.util.Glyphs.getGlyph('add'),
            listeners: {
                click: 'onAdd'
            }
        }
    ]
});

我们不能忘记更新 menu 表以反映电影的 xtype

UPDATE `sakila`.`menu` SET `className`='films' WHERE `id`='11';

我们将在下一章中向这个工具栏添加更多按钮。

处理多对多关联

filmcategory表通过多对多关系相关联。当这种情况发生时,会创建一个矩阵表,包含两列以存储相关实体的 ID 对。有一个矩阵表代表filmcategory表之间的多对多关系,称为film_category,还有一个称为film_actor的表代表filmactor之间的多对多关系。

要在 Ext JS 中表示多对多关系,我们需要将以下代码添加到Film模型中:

manyToMany: {
    FilmCategories: {         //#1
        type: 'Category',     //#2
        role: 'categories',   //#3
        field: 'category_id', //#4
        right: {
            field: 'film_id', //#5
            role: 'films'     //#6
        }
    },
    FilmActors: {
        type: 'Actor',
        role: 'actors',
        field: 'actor_id',
        right: {
            field: 'film_id',
            role: 'films'
        }
    }
}

对于每个多对多关系,我们需要定义一个名称#1)。该名称必须在模式内是唯一的。我们还需要定义一个类型#2)。类型是关联模型的名称——我们可以使用entityName来定义关联的模型。我们还可以定义角色#3),这将作为生成以检索关联数据的方法的名称。我们还需要指定用于标识关联的外键(#4)。由于多对多关系是在两个表之间创建的,我们还可以指定将此模型链接到矩阵表的信息,即字段(外键——#5)以及关联CategoryActor模型中的角色#6)。

Category模型中,我们还将声明多对多关联:

manyToMany: {
    CategoryFilms: {
        type: 'Film',
        role: 'films',
        field: 'film_id',
        right: {
            field: 'category_id',
            role: 'categories'
        }
    }
}

我们同样在Actor模型内部执行此操作:

manyToMany: {
    ActorFilms: {
        type: 'Film',
        role: 'films',
        field: 'film_id',
        right: {
            field: 'actor_id',
            role: 'actors'
        }
    }
}

从服务器加载嵌套 JSON

在服务器端代码中,我们需要检索电影信息及其类别和演员。服务器将返回给 Ext JS 的 JSON 将具有以下格式:

{
   "success":true,
   "data":[
      {
         "film_id":"1",
         "title":"ACADEMY DINOSAUR",
         "description":"A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies",
         "release_year":"2006",
         "language_id":"1",
         "original_language_id":null,
         "rental_duration":"6",
         "rental_rate":"0.99",
         "length":"86",
         "replacement_cost":"20.99",
         "rating":"PG",
         "special_features":"Deleted Scenes,Behind the Scenes",
         "last_update":"2006-02-15 05:03:42",
         "categories":[
            {
               "category_id":"6",
               "name":"Documentary",
               "last_update":"2006-02-15 04:46:27"
            }
         ],
         "actors":[
            {
               "actor_id":"1",
               "first_name":"PENELOPE",
               "last_name":"GUINESS",
               "last_update":"2006-02-15 04:34:33"
            },
            {
               "actor_id":"10",
               "first_name":"CHRISTIAN",
               "last_name":"GABLE",
               "last_update":"2006-02-15 04:34:33"
            }
         ]
      }
   ],
   "total":"1000"
}

注意

服务器端代码包含在此书的源代码中。

如果我们检查films Store 的Film模型实例,我们会看到为每个关联创建了一个函数/方法,如下所示:

从服务器加载嵌套 JSON

注意

当访问model.actors()model.categories()时,方法将返回每个关联的 Store,而不是ActorCategory模型的数组。

更改 ViewModel – 连锁 store

Ext JS 将理解这种关联,并能够创建方法和关联的 store,但我们需要通过在相同的 ViewModel 中添加actorscategories Store 来将 store 添加到会话中,如下所示:

categories: {
 source: 'staticData.Categories',
    autoLoad: true,
    session: true
},
actors: {
 source: 'staticData.Actors',
    autoLoad: true,
    session: true
}

注意高亮的代码。我们正在创建的 stores 使用现有的 stores(我们在上一章中创建的,并且可以通过它们的storeId在应用程序的全局范围内访问)通过source配置。这种能力也在 Ext JS 5 中引入,被称为链式 store(Ext.data.ChainedStore)。链式 store 是一个现有 store 的视图。数据来自source;然而,这个 store 的视图可以独立地进行排序和过滤,而不会对源 store 产生影响。当我们想要有两个不同但数据同步的独立实例时,这非常有用。

Film-Actor – 处理多对多关联

现在我们已经设置了多对多关联,我们可以创建FilmActorsGrid类。这个类将包含以下内容:

Ext.define('Packt.view.film.FilmActorsGrid', {
    extend: 'Ext.grid.Panel',
    xtype: 'film-actors',

    requires: [
        'Packt.util.Glyphs'
    ],

 bind : '{filmsGrid.selection.actors}', //#1
    border: true,

    title: 'Film Actors',
    glyph: Packt.util.Glyphs.getGlyph('actor'),

    columns: [
        {
            text: 'Actor Id',
            width: 80,
            dataIndex: 'actor_id'
        },
        {
            xtype: 'templatecolumn',
            text: 'Actor Name',
            flex: 1,
            tpl: '{first_name} {last_name}' //#2
        }
    ]
});

这个网格包含一个我们迄今为止尚未使用的列,即模板列。当使用这个列时,你可以创建一个模板来显示多个字段(#2),而不是使用renderer函数来做到这一点。

上一段代码展示了如何在详细网格中显示关联数据。当我们从FilmsGrid中选择一部电影时,Actors 网格将自动通过将actors(关联的角色)与 Actors 网格绑定(#1)来显示关联数据。

Film-Category – 处理多对多关联

我们将使用与 Film-Actor 多对多关联相同的处理方法来处理 Film-Category 多对多关联。我们将创建一个名为FilmCategoriesGrid的类。这个类将包含以下内容:

Ext.define('Packt.view.film.FilmCategoriesGrid', {
    extend: 'Ext.grid.Panel',
    xtype: 'film-categories',

    requires: [
        'Packt.util.Glyphs'
    ],

 bind : '{filmsGrid.selection.categories}', //#1
    border: true,

    title: 'Film Categories',
    glyph: Packt.util.Glyphs.getGlyph('category'),

    columns: [
        {
            text: 'Category Id',
            width: 100,
            dataIndex: 'category_id'
        },
        {
            text: 'Category Name',
            flex: 1,
            dataIndex: 'name'
        }
    ]
});

我们还将绑定这个网格的 Store 到从关联(#1)加载的数据。

创建 ViewController

现在缺少的部分是 ViewController,它将处理我们在代码中声明的所有处理程序和监听器。我们将代码分成两个类:包含通用代码并可重用的base.ViewController,以及包含处理Films视图特定细节的代码的film.FilmsController

基础 ViewController

在这个类中,我们将放置所有可以被具有与Films视图相同行为的其他视图重用的通用代码。例如,通过点击 Widget 列的按钮来编辑或删除记录会打开弹出窗口。如果用户点击添加按钮,然后关闭用于创建或编辑信息的弹出窗口。

这个类的代码如下所示:

Ext.define('Packt.view.base.ViewController', {
    extend: 'Ext.app.ViewController',

    requires: [
        'Packt.util.Util',
        'Packt.util.Glyphs'
    ],

    onAdd: function(button, e, options){ //#1
        this.createDialog(null);
    },

    onEdit: function(button){ //#2
        this.createDialog(button.getWidgetRecord());
    },

    onCancel: function(button, e, options){ //#3
        var me = this;
        me.dialog = Ext.destroy(me.dialog);
    },

    onDelete: function(button, e, options){ //#4
        var record = button.getWidgetRecord();
        Ext.Msg.show({
            title:'Delete?',
            msg: 'Are you sure you want to delete?',
            buttons: Ext.Msg.YESNO,
            icon: Ext.Msg.QUESTION,
            fn: function (buttonId){
                if (buttonId == 'yes'){
                    record.drop();
                }
            }
        });
    }
});

在前一章中,你了解到在 MVC 中创建一个通用的控制器来处理来自多个屏幕的事件是可能的。它是通过使用我们创建的组件的通用选择器来实现的。在 MVVM 中,也可以创建一个通用的ViewController,但不是使用通用选择器(因为我们不与选择器一起工作)。这是可能的,如果我们设置一个监听器模式(组件将具有相同的handler名称),并声明一个通用的ViewController。然而,我们还需要为视图指定一个特定的ViewController,这个ViewController将扩展我们的基本ViewControllerViewController中的通用代码通过继承来处理。

FilmsGrid类中,我们有两个处理程序:一个用于编辑Widget 按钮和一个用于删除Widget 按钮。

对于删除按钮(#4),我们只需要询问用户是否确定要删除记录,然后如果我们收到积极的回应,我们使用record.drop()方法来执行删除。drop方法将记录标记为已删除并等待在服务器上删除。当记录被删除时,它将自动从所有关联存储中移除,并且与该记录关联的任何子记录也将被删除(级联删除),具体取决于级联参数。由于视图和存储与会话相关联,当我们调用drop方法时,会话记录将此记录及其关联数据需要被删除,并且存储也会被通知。我们也可以使用存储的remove方法;它会产生相同的结果。

对于添加#1)和编辑#2)处理程序,我们希望打开编辑窗口,以便我们可以修改或创建一个新的电影。我们将使用我们在第六章中处理UsersGroups时使用的方法。createDialog方法将在特定的ViewController中创建,即我们将创建的FilmsController类。这样我们就可以有通用代码,但细节将在特定的ViewController中实现。

对于editdelete处理程序的一个细节是,我们可以轻松地使用按钮(Widget 列)中的getWidgetRecord方法从网格中检索record。这种方法与我们前一章中使用的方法非常不同(在那里我们在 MVC 控制器中创建了一个自定义事件来处理)。

就像我们在第六章中做的那样,用户管理,我们将创建编辑窗口,并且它将包含一个取消按钮。当用户点击此按钮时,我们将销毁该窗口(#3)。

注意

我们可以回到第六章,并修改代码以使用这个ViewController

创建FilmsController

现在,我们将实现Films视图的ViewController。它的初始代码如下;在随后的主题中,我们将向其中添加更多代码:

Ext.define('Packt.view.film.FilmsController', {
    extend: 'Packt.view.base.ViewController',

    alias: 'controller.films'
});

添加或编辑电影

现在,FilmsGrid已经被渲染和加载,并且addedit处理程序已经在ViewController中就位,我们需要创建createDialog方法。但首先,我们需要创建Edit窗口类。

正如我们在本章开头的屏幕截图中所见,编辑窗口有三个标签:一个用于编辑电影详情,另一个用于编辑与电影相关的类别,第三个用于编辑与电影相关的演员。目前,我们将只处理电影详情。

因此,在app/view/film中,我们将创建一个新的视图名为Packt.view.film.FilmWindow。这个类将是一个包含具有标签面板作为item的表单的窗口。在每个标签中,我们将放置电影的详情、类别和演员,如下所示:

Ext.define('Packt.view.film.FilmWindow', {
    extend: 'Packt.view.base.WindowForm', //#1
    xtype: 'film-window',                 //#2

    requires: [
        'Packt.view.film.FilmFormContainer',
        'Packt.view.film.FilmActorsGrid',
        'Packt.view.film.FilmFormCategories'
    ],

    width: 537,

    items: [
        {
            xtype: 'form',
            reference: 'filmForm',  //#3
            layout: {
                type: 'fit'
            },
            items: [{
                xtype: 'tabpanel', //#4
                activeTab: 0,
                items: [{
                    xtype: 'film-form-container', //#5
                    glyph: Packt.util.Glyphs.getGlyph('film')
                },{
                    xtype: 'film-categories-form', //#6
                    glyph: Packt.util.Glyphs.getGlyph('category')
                }
                //film actors here
            ]
        }
    ]
});

这个窗口扩展自我们将要创建的自定义类(#1)。至于网格行的创建和编辑,我们总是使用窗口,因此我们可以创建一个具有通用配置的超级窗口类,并在我们的应用程序中使用它。我们将在一分钟内创建这个新类;让我们首先完成对这个类的概述。我们不能忘记声明一个xtype配置(#2);我们将在ViewController中稍后使用这个xtype配置。

在这个窗口内部,我们有一个表单(#3),我们需要声明一个引用以便在ViewController中轻松检索。在表单内部,我们有一个标签面板(#4),其中包含一个包含电影信息的标签(#5)——我们将为它创建一个单独的类,以及类别(#6),我们也将为它创建一个单独的类。

这个类的最后一部分是以下代码中展示的演员详情:

{
    xtype: 'film-actors',    //#7
    reference: 'actorsGrid', //#8
    dockedItems: [{
        dock: 'top',
        items: [
            {
                xtype: 'button',
                text: 'Search and Add',
                glyph: Packt.util.Glyphs.getGlyph('searchAndAdd'),
                listeners: {
                    click: 'onAddActor' //#9
                }
            },
            {
                xtype: 'button',
                text: 'Delete',
                glyph: Packt.util.Glyphs.getGlyph('destroy'),
                listeners: {
                    click: 'onDeleteActor' //#10
                }
            }
        ]
    }]
}

对于演员详情,我们将重用Films视图中显示的Actors网格(#7)。我们还将添加一个引用(#8),因为它在我们处理搜索和添加屏幕上的onAddActor#9)监听器时将非常有用。最后,我们还需要一个用于删除按钮的监听器(#10)。

添加删除演员按钮将向film_actor表中添加和删除条目。

Packt.view.base.WindowForm

我们已经实现的所有编辑窗口都是一个具有适应布局的窗口,通常在其中包含一个表单面板。该窗口还包含一个取消和一个保存按钮。由于所有这些配置都是我们组件的默认配置,我们可以为它们创建一个超级窗口:

Ext.define('Packt.view.base.WindowForm', {
    extend: 'Ext.window.Window',
    alias: 'widget.windowform',

    requires: [
        'Packt.util.Util',
        'Packt.util.Glyphs',
        'Packt.view.base.CancelSaveToolbar'
    ],

    height: 400,
    width: 550,
    autoScroll: true,
    layout: {
        type: 'fit'
    },
    modal: true,
    closable: false,

    bind: {
        title: '{title}', //#1
        glyph: '{glyph}'  //#2
    },

    dockedItems: [{
        xtype: 'cancel-save-toolbar'
    }]
});

注意,我们在initConfig方法中没有声明配置(这个方法在这个类中不存在)。这意味着这个类是一个基础类,任何东西都可以在子类中被覆盖。

这里一个重要的细节是,在这个窗口中使用的标题#1)和图标#2)配置可以绑定到 ViewModel 的信息。我们将在createDialog方法中处理这些细节。

这个窗口类使用CancelSaveToolbar。这个工具栏的代码如下:

Ext.define('Packt.view.base.CancelSaveToolbar', {
    extend: 'Ext.toolbar.Toolbar',
    xtype: 'cancel-save-toolbar',

    requires: [
        'Packt.util.Glyphs'
    ],

    dock: 'bottom',
    ui: 'footer',
    layout: {
        pack: 'end',
        type: 'hbox'
    },
    items: [
        {
            xtype: 'button',
            text: 'Save',
            glyph: Packt.util.Glyphs.getGlyph('save'),
            listeners: {
                click: 'onSave' //#3
            }
        },
        {
            xtype: 'button',
            text: 'Cancel',
            glyph: Packt.util.Glyphs.getGlyph('cancel'),
            listeners: { 
                click: 'onCancel' //#4
            }
        }
    ]
});

在前面的类中,有对保存按钮(#3)的监听器,我们将在FilmsController类中处理它,以及对于取消按钮(#4),它由基础ViewController类处理。

小贴士

我们可以回到我们在第六章中实现的代码,用户管理和第七章,静态数据管理,并进行重构以使用基础类和工具栏类。这就是 Ext JS 及其面向对象方法的好处:它允许你重用代码,并且你可以像在其他面向对象语言中一样重构它,而且没有头痛的问题。

电影表单

TabPanel 的第一个项目是film-form-container。在这个类中,我们将声明所有代表film表列的字段。

让我们回到 Sakila 文档,看看film表的字段(dev.mysql.com/doc/sakila/en/sakila-structure-tables-film.html)。您也可以参考本章的第一张图片:

  • film_id: 这是表的主键,具有唯一值。因此,对于这个字段,我们可以使用隐藏字段来控制它。

  • 标题: 这是电影的标题。因此,我们可以为它使用一个文本字段。数据库中的最大长度为 255,因此我们还需要添加验证。

  • 描述: 这是电影的简短描述或剧情摘要。由于描述长度可达 5,000 个字符,我们可以使用文本区域来表示它。

  • release_year: 这是电影发布的年份。这可以是一个数值字段,最小值为 1950 年,直到当前年份加 1(比如说我们想要添加一部明年将要上映的电影)。

  • language_id: 这是一个指向语言表的键。它标识电影的语种。这可以是一个带有语言存储的组合框(当加载应用程序时已经填充)。

  • original_language_id: 这是一个指向语言表的键,用于标识电影的原始语言。当电影被翻译成新语言时使用。此外,这可以是一个带有语言存储的组合框(当加载应用程序时已经填充)。

  • rental_duration: 这是租赁期限的天数。这可以是一个数字字段,最小值为1,最大值为10(让我们给最大值设置一个限制)。

  • rental_rate:这是在rental_duration列指定的期间租用电影的费用。这也可以是一个数字字段。最小值是0,最大值是5,并且我们需要允许小数值。

  • length:这是电影以分钟为单位的时间长度。length列也可以是一个介于 1 到 999 之间的数值字段。

  • replacement_cost:这是如果电影未归还或损坏归还时向客户收取的金额。这也是一个数值字段。让我们设置最小值为0,最大值为100

  • rating:这是分配给电影的评级。它可以是一组GPGPG-13RNC-17中的任何一个。由于这些有固定值,我们可以在单选按钮组或组合框中代表它们。我们将使用组合框。

  • special_features:这列出了 DVD 上包含哪些常见的特殊功能。它可以包括零个或多个预告片、评论、删减场景和幕后花絮。由于这可以是一个或多个,我们可以使用 Ext JS 5 中引入的 TagField。我们还可以使用复选框或允许多选的组合框。

首先让我们声明类结构,如下所示:

Ext.define('Packt.view.film.FilmFormContainer', {
    extend: 'Ext.panel.Panel',
    xtype: 'film-form-container',

    requires: [
        'Packt.util.Util',
        'Packt.util.Glyphs'
    ],

    bodyPadding: 10,
    layout: {
        type: 'anchor'
    },
    title: 'Film Information',
    defaults: {
        anchor: '100%',
        msgTarget: 'side',
        labelWidth: 105
    },

    items: [
        //fields
    ]
});

字段将位于一个使用锚布局的面板(它将成为一个标签页)内,每个字段将占用所有可用的水平空间(anchor: 100%)。标签宽度为105像素,任何错误信息都将显示在字段的side上。到目前为止,我们还没有收到任何消息。

让我们声明前两个字段——titlerelease_year

{
    xtype: 'textfield',
    fieldLabel: 'Title',
    afterLabelTextTpl: Packt.util.Util.required,
    bind : '{currentFilm.title}' //#1
},
{
    xtype: 'numberfield',
    fieldLabel: 'Release Year',
    allowDecimals: false,               //#2
    bind : '{currentFilm.release_year}' //#3
},

这两个值都绑定到名为currentFilm的记录的字段上(我们将在FilmsController中稍后创建)。Release Year是一个数值字段,由于我们希望值是整数,因此不允许用户输入小数(#2)。

备注

关于表单和字段验证的更多信息,请参阅docs.sencha.com/extjs/5.0/components/forms.html

接下来,我们有语言字段,如下所示:

{
    xtype: 'combobox',
    fieldLabel: 'Language',
    displayField: 'name',
    valueField: 'language_id',
    queryMode: 'local',
    store: 'staticData.Languages', //#4
    afterLabelTextTpl: Packt.util.Util.required,
    bind : '{currentFilm.language_id}' //#5
},
{
    xtype: 'combobox',
    fieldLabel: 'Original Language',
    displayField: 'name',
    valueField: 'language_id',
    queryMode: 'local',
    store: 'staticData.Languages',
    bind : '{currentFilm.original_language_id}' //#6
},

由于这两个组合框都代表语言,它们的配置将完全相同,除了fieldLabelbind#5#6)。

注意,我们正在使用相同的存储器(#4)来处理这两个字段,并且我们希望它们具有相同的值,这意味着如果用户在静态数据的语言 GridPanel 中添加或更改语言,我们希望这些更改同时应用于这些存储器,这就是为什么我们使用与静态数据模块相同的存储器。

然后我们有四个数值字段:rental_durationrental_ratelengthreplacement_cost

{
    xtype: 'numberfield',
    fieldLabel: 'Rental Duration',
    allowDecimals: false,
    afterLabelTextTpl: Packt.util.Util.required,
    bind : '{currentFilm.rental_duration}'
},
{
    xtype: 'numberfield',
    fieldLabel: 'Rental Rate',
    step: 0.1,
    afterLabelTextTpl: Packt.util.Util.required,
    bind : '{currentFilm.rental_rate}'
},
{
    xtype: 'numberfield',
    fieldLabel: 'Length (min)',
    allowDecimals: false,
    bind : '{currentFilm.length}'

},
{
    xtype: 'numberfield',
    name: 'replacement_cost',
    fieldLabel: 'Replacement Cost',
    step: 0.1,
    afterLabelTextTpl: Packt.util.Util.required,
    bind : '{currentFilm.replacement_cost}'
},

有一件事非常重要:无论何时我们都有数值字段并且想要从模型中加载它们,我们需要模型中的字段也是数值的(intfloat);否则,表单将无法正确加载值。

然后我们有带有其store的评级组合框,如下所示:

{
    xtype: 'combobox',
    fieldLabel: 'Rating',
    displayField: 'text',
    valueField: 'text',
    queryMode: 'local',
    bind: {
        value: '{currentFilm.rating}', //#6
        store: '{ratings}'             //#7
    }
},

我们有一个与这个组合框绑定的value#6)和一个store#7)。我们将在FilmsModel中创建这个 Store。

最后,我们有tagfieldtextareafield

{
    xtype: 'tagfield',
    fieldLabel: 'Special Features',
    displayField: 'text',
    valueField: 'text',
    filterPickList: true,
    queryMode: 'local',
    publishes: 'value',
    stacked: true,
    bind: {
        value: '{specialFeatures}', //#8
        store: '{special_features}' //#9
    }
},
{
    xtype: 'textareafield',
    fieldLabel: 'Description',
    bind : '{currentFilm.description}'
}

Tag field 是在 Ext JS 5 中引入的,其行为与允许您选择多个值的组合框非常相似。为了设置选定的值,我们需要传递一个数组(#8)。在Film模型中,special_features是一个字符串。因此,我们将在 ViewModel 中也使用公式来处理这些值。我们还将创建一个store配置(#9)在 ViewModel 中来表示这个静态 Store。

电影分类

现在我们已经涵盖了电影详情部分,我们可以处理最复杂的部分,即与分类演员表的关联。分类演员表与电影表有多个对多个的关联。

如在电影表单中声明的那样,我们将声明一个新的类来表示这个标签页:

Ext.define('Packt.view.film.FilmFormCategories', {
    extend: 'Ext.container.Container',

    xtype: 'film-categories-form',

    requires: [
        'Ext.view.MultiSelector'
    ],

    title: 'Film Categories',

    layout: 'fit',

    items: [{
        xtype: 'multiselector',
        title: 'Selected Categories',
        reference: 'categoriesMultiSelector',

        fieldName: 'name',

        viewConfig: {
            deferEmptyText: false,
            emptyText: 'No categories selected'
        },

        bind: '{currentFilm.categories}', //#1

        search: {
            field: 'name',
            store: {
                type: 'categories', //#2
                autoLoad: true
            }
        }
    }]
});

这个类包含一个多选器,这是 Ext JS 5 中引入的新组件。它创建了一个网格来渲染选定的值,并且它还将显示一个红色交叉符号来移除不需要的值。我们可以在其 Store(#1)中设置任何选定的值。此组件还允许您使用由search配置设置的加号添加值。我们还需要设置一个store#2)来提供用户可以选择的选项。

电影演员

与电影的关系,演员与电影的关系非常类似于分类电影表之间的关系,意味着它也是一个多对多关系。我们将以与处理电影分类不同的方式来处理演员表的多对多关系。由于可用的分类选项有限,我们可以使用多选器组件来表示它。我们不知道我们数据库中可以有多少演员,所以这要复杂一些。我们将采用的方法是显示一个网格来渲染选定的演员,并有一个添加按钮,以便用户可以搜索并添加所需值。

我们已经在FilmWindow类内部声明了网格。待定的是将显示弹出窗口以搜索可用演员的类。

搜索演员 – 实时搜索组合框

Live Search 组合框的想法是为用户显示搜索屏幕和一个组合框字段,用户可以在其中输入几个字符,然后系统将进行实时搜索,显示与用户搜索匹配的演员。所有与搜索匹配的演员都将显示为组合框的项目,组合框还将具有分页功能。当用户选择演员时,我们将显示其last_namefirst_name。除了演员姓名外,我们还将显示该演员出演的电影列表。

模型

首先,我们需要一个模型来表示从服务器检索的信息。我们将检索演员信息以及该演员已经制作的电影。因此,我们可以创建一个从Actor模型扩展的模型,在SearchActor模型中,我们只需要声明缺失的字段:

Ext.define('Packt.model.film.SearchActor', {
    extend: 'Packt.model.staticData.Actor',

    fields: [
        { name: 'film_info' }
    ]
});
存储

接下来,我们需要一个存储库来加载SearchActor模型集合,如下所示:

Ext.define('Packt.store.film.SearchActors', {
    extend: 'Ext.data.Store',

    requires: [
        'Packt.model.film.SearchActor'
    ],

    alias: 'store.search-actors',

    model: 'Packt.model.film.SearchActor',

    pageSize: 2,

    proxy: {
        type: 'ajax',
        url: 'php/actor/searchActors.php',

        reader: {
            type: 'json',
            rootProperty: 'data'
        }
    }
});

在服务器上,我们将使用actor_info视图来检索信息。然而,组合框还传递了三个额外的参数:用于分页的startlimit,以及一个名为query的参数,包含用户输入的用于实时搜索的文本。

我们的SELECT查询将类似于以下内容:

$start = $_REQUEST['start'];
$limit = $_REQUEST['limit'];
$query = $_REQUEST['query'];

//select the information
$sql = "SELECT * FROM actor_info ";
$sql .= "WHERE first_name LIKE '%" . $query . "%' OR ";
$sql .= "last_name LIKE '%" . $query . "%' ";
$sql .= "LIMIT $start,  $limit";

由于我们正在处理分页,我们不能忘记计算与搜索匹配的记录数,并将结果返回到 JSON 的total属性中:

$sql = "SELECT count(*) as num FROM actor_info ";
$sql .= "WHERE first_name LIKE '%" . $query . "%' OR ";
$sql .= "last_name LIKE '%" . $query . "%' ";

现在,我们能够根据用户输入的搜索文本检索信息。

实时搜索组合框

我们接下来的步骤是实现将要提供搜索工具的视图。因此,我们将创建一个从Ext.window.Window扩展的类,在这个类中,我们将有一个组合框,它将提供所有进行实时搜索的功能。代码如下所示:

Ext.define('Packt.view.film.FilmSearchActor', {
    extend: 'Ext.window.Window',
    xtype: 'search-actor',

    requires: [
        'Packt.store.film.SearchActors'
    ],

    width: 600,
    bodyPadding: 10,
    layout: {
        type: 'anchor'
    },
    title: 'Search and Add Actor',
    autoShow: true,
    closable: false,
    glyph: Packt.util.Glyphs.getGlyph('searchAndAdd'),
    reference: 'search-actor',

    items: [
        {
            //combobox // #1
        }, {
            xtype: 'component',
            style: 'margin-top:10px',
            html: 'Live search requires a minimum of 2 characters.'
        }
    ]
});

在底部,只有一个注释,提示用户至少输入两个字符,以便实时搜索可以工作,如下面的截图所示:

实时搜索组合框

现在,让我们看看之前代码中#1位置所对应的组合框的代码:

xtype: 'combo',
reference: 'comboActors',   //#2
displayField: 'first_name', //#3
valueField: 'actor_id',     //#4
typeAhead: false,
hideLabel: true,
hideTrigger:true,           //#5
anchor: '100%',
minChars: 2,                //#6
pageSize: 2,                //#7
store: {
    type: 'search-actors'   //#8
},

displayTpl: new Ext.XTemplate( //#9
        '<tpl for=".">' +
        '{[typeof values === "string" ? values : values["last_name"]]}, ' +
        '{[typeof values === "string" ? values : values["first_name"]]}' +
        '</tpl>'
),

listConfig: {                //#10
    loadingText: 'Searching...',
    emptyText: 'No matching posts found.',

    // Custom rendering template for each item
    getInnerTpl: function() {
        return '<h3><span>{last_name}, {first_name}</span></h3></br>' +
            '{film_info}';
    }
}

为了开始,我们将声明reference,这样我们可以在 ViewController 中轻松检索此组件(#2)。像往常一样,我们需要一个store声明(#8)来填充组合框。在这个例子中,我们通过其类型实例化存储库。因此,我们需要在这个类的requires中声明存储库的完整名称。

然后我们需要displayField#3)。displayField将只显示当从实时搜索中选择演员时的演员的first_name。然而,我们希望显示last_namefirst_name。因此,为了能够做到这一点,我们需要重写displayTpl模板(#9)。这将是我们得到的结果:

实时搜索组合框

接下来,我们有valueField#4),它是所选演员的 ID;我们将隐藏下拉箭头(称为trigger#5),以便实时搜索可以工作。用户需要至少输入两个字符(#6),组合框将每页显示两个演员(#7)。

然后,我们有listConfig#10),在这里我们可以配置加载文本和空文本,以及显示演员信息的模板。基本上,我们在顶部显示last_namefirst_name并加粗,在下一行显示该演员已经制作的所有电影。

补充 ViewModel

在 ViewModel 中,我们需要完成两个待办事项:添加 ratingsspecial_features 存储,并实现我们在电影详情表单中使用的 specialFeatures 公式。

那么,让我们开始声明我们的 ViewModel 中的存储,如下所示:

ratings: {
    model: 'Packt.model.TextCombo',
    data : [ // ENUM('G','PG','PG-13','R','NC-17')
        ['G'],
        ['PG'],
        ['PG-13'],
        ['R'],
        ['NC-17']
    ],
    session: true
},
special_features: {
    model: 'Packt.model.TextCombo',
    data : [
        ['Trailers'],
        ['Commentaries'],
        ['Deleted Scenes'],
        ['Behind the Scenes']
    ],
    session: true
}

这两个存储都有预定义的 data,这意味着它们是 ArrayStore 的实例。这种类型的 Store 在这种情况下非常有用。这两个存储使用的模型如下所示:

Ext.define('Packt.model.TextCombo', {
    extend: 'Ext.data.Model',

    idProperty: 'text',

    fields: [
        { name: 'text' }
    ]
});

模型非常简单,只有一个字段。我们可以为任何创建用于组合框的 Store 重复使用这个模型。

与公式和双向数据绑定一起工作

我们创建了一个标签字段来声明电影的特殊功能。这个字段需要设置一个值数组,并且还需要返回一个值数组作为字段值。模型中的输出字段 special_features 是一个字符串,显然这不是一个数组!直到 Ext JS 4,我们不得不在控制器(或者在代码的其他地方,但最好是控制器)中手动编码和解码值。随着 Ext JS 5 的推出以及 ViewModel 的引入,我们可以使用一个名为公式的功能。

可以创建类似于我们可以在模型中声明的额外 fields 的简单公式(我们在 User 模型中声明 groupName),也可以声明更复杂的 formulas。在 Ext JS 示例中,我们可以找到一些如何使用简单 formulas 的示例。

让我们看看绑定到 FilmFormContainer 类中的 tagfield 的名为 specialFeatures 的公式:

formulas: {
    specialFeatures : { //#1

        bind: {
            bindTo: '{currentFilm.special_features}', //#2
            deep: true                                //#3
        },

        get: function(value){ //#4
            var values = value ? value.split(',') : [],
                texts = [];
            values.forEach(function(item){
                texts.push(Ext.create('Packt.model.TextCombo',{
                    text: item
                }));
            });
            return texts;
        },

        set: function(value){ //#5
            if (value){
                this.get('currentFilm').set('special_features', value.join());
            }
        }
    }
}

我们需要做的第一件事是为我们的 formulas 声明命名(#1)。我们可以将我们的公式绑定到现有的值(例如选择)。在我们的例子中,我们绑定到 currentFilm.special_featuresspecial_features 属性(#2)——我们将在 ViewController 中的稍后时间传递给 编辑 窗口)。我们还指定这是一个 deep 数据绑定(#3),这意味着 currentFilm.special_features 中发生的任何更改都将更新公式,或者通过其方法发生的任何更新也将更新 currentFilm.special_features

我们还可以为公式定义一个获取器和设置器方法。首先,我们定义一个 get 方法(#4)。这个方法接收来自 currentFilm.special_featuresvalue,分割字符串,并将其转换为数组,将被标签字段使用。同样,我们有一个 set 方法(#5),它将接收在标签字段中设置的 value,将其转换为字符串,并更新 currentFilm.special_features。只需记住 currentFilmFilm 模型的实例。

电影 ViewController

在前面的章节中,我们已经介绍了一些如何保存数据的示例。我们使用了 submit 表单、Ajax request 以及 Store 中的写入资源。在本章中,让我们关注我们尚未实现的功能。不要担心,完整的实现可以在本书附带源代码中找到。

createDialog 方法

我们为我们的应用程序创建了所有视图。在基础 ViewController 中,我们创建了添加和编辑按钮的处理程序,并且两者都调用 createDialog 方法,这是我们接下来要开发的。

策略是,当用户点击 Add 按钮时显示一个空的 Edit 窗口,而当用户点击 Edit 按钮时显示选定的电影。此方法的源代码如下:

createDialog: function(record){

    var me = this,
        view = me.getView(),        //#1
        glyphs = Packt.util.Glyphs;

    me.isEdit = !!record;           //#2
    me.dialog = view.add({          //#3
        xtype: 'film-window',
        viewModel: {                //#4
            data: {                 //#5
                title: record ? 'Edit: ' + record.get('title') : 'Add Film',
                glyph: record ? glyphs.getGlyph('edit') : glyphs.getGlyph('add')
            },
            links: {                     //#6
                currentFilm: record || { //#7
                    type: 'Film',
                    create: true
                }
            }
        },
        session: true //#8
    });

    me.dialog.show(); //#9
}

我们首先要做的是获取视图的引用,即 Films 类 (#1)。接下来,我们还将创建一个 isEdit 标志 (#2) 并将其分配给 ViewController,这样我们就可以稍后访问其他方法(如 save 方法)。

然后,我们将实例化 Edit 窗口,将其添加到视图 (#3)。当我们向主视图添加子视图时,它将继承 ViewModel 和 ViewController。然而,在这种情况下,我们正在为 Edit 窗口的 ViewModel 设置特定的配置 (#4),这意味着它将能够访问已经存在的配置以及我们设置的配置,例如 titleglyph,这是预定义的数据 (#5)。

接下来,我们将创建一个链接 (#6) 到一个名为 currentFilm 的记录((#7)),我们在 Edit 窗口的绑定配置中使用过。如果是编辑,它将链接到网格中的选定行;否则,我们创建一个新的 Film 模型实例。

我们还将为这个视图创建一个子 session (#8)。当我们讨论 save 方法时,我们将讨论 session

最后,我们显示 Edit 窗口弹出 (#9)。

从 Live Search 获取选定的演员

当用户搜索演员并点击 Add Selected 时,我们将在 ViewControlleronSaveActors 方法中处理该事件。此方法的逻辑是,我们需要获取 combobox 中选定的演员 ID 并在 actors Store 中搜索其值。一旦我们有了 Actor 模型实例,我们就可以将其添加到 actorsGrid Store 中。代码如下:

onSaveActors: function(button, e, options){
    var me = this,
        value = me.lookupReference('comboActors').getValue(), //#1
        store = me.getStore('actors'),                        //#2
        model = store.findRecord('actor_id', value),          //#3
        actorsGrid = me.lookupReference('actorsGrid'),        //#4
        actorsStore = actorsGrid.getStore();                  //#5

    if (model){
        actorsStore.add(model); //#6
    }

    me.onCancelActors(); //#7
}  

首先,我们获取 combobox 的引用 (#1) 并获取其 value,这将返回选定演员的 ID。接下来,我们将获取在 FilmsModel 中声明的 actors Store 的引用 (#2)。我们将在 Store 中搜索选定的演员 (#3);它将返回 Actor 模型引用或 null(如果演员不存在)。然后我们获取 actorsGrid 的引用((#4)——在 films 表单内部),我们还获取其 Store (#5)。

如果找到一个演员,我们将将其添加到actorsGrid存储库中(#6)。这个网格绑定到所选电影演员的关联,因此如果从网格存储中添加或删除演员,它也将从关联中添加或删除。这是 Ext JS 5 中双向数据绑定的另一个例子。

最后,我们使用以下方法关闭 Live Search 弹出窗口(#7):

onCancelActors: function(button, e, options){
    var me = this;
    me.searchActors = Ext.destroy(me.searchActors);
}

保存表单和会话操作

现在是保存任何更新、删除或创建的数据的时候了。我们在表单中进行的任何更改都将保存到会话中(Ext.data.Session)。会话是在 Ext JS 5 中引入的,当我们在处理关联时非常有用。我们通过在它中添加配置session: true将一个会话添加到我们的电影视图中。然后,ViewModel 中的所有存储都将绑定到会话,这意味着在存储或会话中进行的任何更改都将同步。

让我们看看onSave方法:

onSave: function(button, e, options){
    var me = this,
        dialog = me.dialog,
        form = me.lookupReference('filmForm'),
        isEdit = me.isEdit,
        session = me.getSession(), //#1
        id;

    if (form.isValid()) {
        if (!isEdit) {
            id = dialog.getViewModel().get('currentFilm').id; //#2
        }
        dialog.getSession().save(); //#3
        if (!isEdit) {
            me.getStore('films').add(session.getRecord('Film', id)); //#4
        }
        me.onCancel();
    }

    var batch = session.getSaveBatch(); //#5
    if (batch){
        batch.start();                  //#6
    }
}

我们将从会话中获取待保存的信息并将其保存在服务器上。首先,我们需要获取session#1)。然后,如果是一部新电影,我们将获取电影的id(#2)——Ext JS 为每个模型创建一个随机临时 ID,通常以实体名称和顺序号命名)。这个id在我们将其保存到数据库并使用数据库表顺序 ID 后将被覆盖。

记住,当我们创建对话框时,我们分配了一个子会话给编辑窗口?这允许我们在不提交数据的情况下工作,这意味着我们可以通过销毁编辑窗口轻松地回滚更改。当我们想将子会话中的数据正式保存到Films会话中时,我们可以从编辑窗口调用getSession方法并保存它(#3)。这将保存子会话数据到Film会话。

接下来,如果是一部新电影,我们还想将记录添加到films存储库中(这样它也可以在FilmsGrid中显示——(#4))。

从会话中保存信息有两种不同的方式。第一种方式是使用batchExt.data.Batch),可以从会话中检索(#5),第二种方式是执行其start#6)方法。这将触发 CRUD 操作,并使用proxy详细信息连接到会话中待保存模型的服务器。

自定义 Writer – 保存相关数据

然而,会话不会保存相关数据。如果我们创建一个电影、类别和演员,每个相应模型将触发创建操作,但不会将要在多对多矩阵表中保存的数据发送到服务器。

我们可以创建一个自定义的writer类,将任何相关数据作为一个批次发送到服务器。然后,我们需要在服务器上处理相关数据的适当 CRUD 操作。代码如下:

Ext.define('Packt.ux.data.writer.AssociatedWriter', {
    extend: 'Ext.data.writer.Json',
    alias: 'writer.associatedjson',

    constructor: function(config) {
        this.callParent(arguments);
    },

    getRecordData: function (record, operation) {
        record.data = this.callParent(arguments);
        Ext.apply(record.data, record.getAssociatedData());
        return record.data;
    }
});

然后,我们需要回到Packt.model.Base类,将此writer类添加到requires声明中,并按以下方式更改写入器类型:

writer: {
 type: 'associatedjson',
    //...
},

所有相关数据都将发送到服务器;同样地,当我们从服务器读取信息时,我们也会接收到嵌套的 JSON。

手动保存会话数据

将会话数据保存到服务器的第二种方法是手动操作。如果我们使用会话的getChanges方法,它将返回一个对象,其中包含所有待保存到服务器上的信息,包括关联数据。

例如,如果我们尝试编辑一个影片,添加一些演员信息和分类信息,并调用JSON.stringify(session.getChanges(), null, 4),我们将得到以下类似的输出:

{
    "Film": {
        "U": [
            {
                "title": "ACADEMY DINOSAUR - edit",
                "language_id": "2",
                "original_language_id": "3",
                "film_id": "1",
                "id": null
            }
        ],
        "categories": {
            "D": {
                "1": [
                    "6"
                ]
            },
            "C": {
                "1": [
                    "7",
                    "8",
                    "9"
                ]
            }
        },
        "actors": {
            "D": {
                "1": [
                    "1"
                ]
            },
            "C": {
                "1": [
                    "71"
                ]
            }
        }
    }
}

这意味着我们正在更新(U)ID 为1的影片的一些字段,从影片1中删除(D)ID 为6的分类,并将分类789添加(C)到影片1中。我们还在影片1中删除(D)演员1并添加(C)演员71。请注意,categoriesactors是我们为Film模型创建的多对多关联的名称。

我们还可以使用此对象手动在服务器上保存数据。

摘要

在本章中,你学习了如何实现一个更复杂的屏幕来管理从数据库中获取的库存信息。你还学会了如何处理多对多关联。你学习了如何使用不同的表单字段以及如何进行实时搜索。此外,你还学习了如何从会话中保存数据。

在下一章中,我们将学习如何将一些不属于 Ext JS API 原生功能的额外功能添加到我们迄今为止已经开发的屏幕中,例如打印、导出到 Excel 和导出到 PDF,以及 GridPanel 的内容。我们还将学习如何实现图表并将它们导出为图像和 PDF。

第九章。添加额外功能

我们几乎到了应用程序的最后阶段。Ext JS 提供了强大的功能,但还有一些功能我们需要借助其他技术自己编码。尽管我们拥有具有分页、排序和过滤功能的 GridPanel,但有时用户可能会对应用程序有更高的期望。添加打印、导出到 Excel 和 PDF 以及将图表导出到图片和 PDF 的功能可以为应用程序增添巨大价值,并取悦最终用户。

因此,在本章中,我们将涵盖:

  • 打印 GridPanel 的记录

  • 将 GridPanel 信息导出为 PDF 和 Excel

  • 创建图表

  • 将图表导出到 PDF 和图片

  • 使用第三方插件

将 GridPanel 导出为 PDF 和 Excel

我们将要实现的第一项功能是将 GridPanel 的内容导出到 PDF 和 Excel。我们将为前一章中实现的 Films GridPanel 实现这些功能。然而,对于您可能在 Ext JS 应用程序中拥有的任何 GridPanel,逻辑都是相同的。

我们首先要做的是将导出按钮添加到 GridPanel 工具栏中。我们将添加三个按钮:一个用于打印 GridPanel 的内容(我们将在稍后开发这个功能,但现在让我们先添加这个按钮),一个用于 导出到 PDF 的按钮,以及一个用于 导出到 Excel 的按钮:

将 GridPanel 导出为 PDF 和 Excel

记住,在前一章中,我们创建了一个工具栏,Packt.view.base.TopToolBar。我们将在该工具栏上添加这三个按钮:

items: [
    //Add Button
    {
        xtype: 'tbseparator'
    },
    {
        xtype: 'button',
        text: 'Print',
        glyph: Packt.util.Glyphs.getGlyph('print'),
        listeners: {
            click: 'onPrint'
        }
    },
    {
        xtype: 'button',
        text: 'Export to PDF',
        glyph: Packt.util.Glyphs.getGlyph('pdf'),
        listeners: {
            click: 'onExportPDF'
        }
    },
    {
        xtype: 'button',
        text: 'Export to Excel',
        glyph: Packt.util.Glyphs.getGlyph('excel'),
        listeners: {
            click: 'onExportExcel'
        }
    }
]

所有按钮都有 listeners,我们将在 ViewController 中处理。在这种情况下,按钮将位于 FilmsController 类中。

Glyphs 类中,我们还将添加以下属性来表示图标:

print: 'xf02f',
pdf: 'xf1c1',
excel: 'xf1c3'

导出到 PDF

现在按钮已经在 Films GridPanel 中显示,是时候回到 FilmsController 并添加这些功能了。

我们将监听第一个按钮的 导出到 PDF click 事件。当用户点击此按钮时,我们将执行以下方法:

onExportPDF: function(button, e, options) {
    var mainPanel = Ext.ComponentQuery.query('mainpanel')[0]; //#1

    var newTab = mainPanel.add({
        xtype: 'panel',
        closable: true,
        glyph: Packt.util.Glyphs.getGlyph('pdf'),
        title: 'Films PDF',
        layout: 'fit',
        html: 'loading PDF...',
        items: [{
            xtype: 'uxiframe',                 //#2
            src: 'php/pdf/exportFilmsPdf.php' //#3
        }]
    });

    mainPanel.setActiveTab(newTab); //#4
}

我们想要实现的是,当用户点击 导出到 PDF 按钮时,将打开一个新标签页(#4),其中包含 PDF 文件。这意味着我们需要获取我们声明的作为应用程序视口中心项的 Main Panel 类(xtype mainpanel)(#1),向其中添加一个新标签页,由于 PDF 文件将包含在其中,我们可以将其实现为 iFrame。要在 Ext JS 中实现 iFrame,我们可以使用 SDK 内部分发的 iFrame 插件(#2)。

在 ViewController 内部,我们可以访问 Films 视图,但我们需要访问 mainpanel。我们可以使用 getView 方法获取 Films 视图,然后使用 up 方法获取 mainpanel,或者我们可以使用 Ext.ComponentQuery 来查询 mainpanel。记住,Ext.ComponentQuery 返回所有匹配结果的数组,但正如我们所知,应用程序中只有一个 mainPanel,因此我们可以检索第一个位置。

最重要的是:Ext JS 并没有提供原生的 导出为 PDF 功能。如果我们想让应用程序具备这个功能,我们需要使用不同的技术来实现。在这种情况下,PDF 将在服务器端生成(#3),我们将在 iFrame 内部显示其输出。

当我们执行代码时,我们将得到以下输出:

导出为 PDF

在服务器上生成 PDF 文件 – PHP

由于我们需要在服务器端生成文件,因此我们可以使用服务器端使用的任何框架或库。我们可以使用 TCPDF (www.tcpdf.org/)。还有其他库可供选择,你可以使用你最熟悉的那个。

小贴士

如果你使用的是 Java,你可以使用 iText (itextpdf.com/),如果你使用的是 .NET,你可以使用 excellibrary (code.google.com/p/excellibrary/)。

使用 JavaScript – HTML5 生成和查看 PDF 文件

多亏了 HTML5,我们也可以使用 HTML5 API 生成 PDF 文件。我们可以使用一些解决方案来生成文件,而无需使用任何服务器端代码,仅使用 JavaScript。其中之一是使用 jsPDF (github.com/MrRio/jsPDF)。

默认情况下,浏览器将使用用户在计算机上安装的任何 PDF 查看器软件来查看 PDF 文件。也可以使用用 JavaScript 开发的 PDF 查看器 pdf.js。这个解决方案是由 Mozilla 实现和维护的 (github.com/mozilla/pdf.js/)。还有一个基于 pdf.js 开发的 Ext JS 插件 (market.sencha.com/extensions/pdf-panel-without-plugin-needed)。

导出为 Excel

要将 GridPanel 导出为 Excel,我们也将使用服务器端技术来帮助我们。

在 Ext JS 方面,我们唯一需要做的就是调用以下将生成 Excel 文件的 URL:

onExportExcel: function(button, e, options) {
    window.open('php/pdf/exportFilmsExcel.php');  
}

在服务器端,我们将使用 PHPExcel (phpexcel.codeplex.com/) 库来帮助我们生成 Excel 文件。

小贴士

如果你使用的是 Java,你可以使用 Apache POI 库 (poi.apache.org/),如果你使用的是 .NET,你可以使用 excellibrary (code.google.com/p/excellibrary/)。

如果你想将任何其他 Ext JS 组件的 GridPanel 或其他内容导出到 Excel、PDF、.txt 或 Word 文档,你可以使用相同的方法。

注意

还有一个 Ext JS 插件可以将网格导出为 Excel 文件:goo.gl/E7jif4

使用 GridPrinter 插件打印 GridPanel 内容

我们将要实现的下一种功能是打印 GridPanel 的内容。当用户点击 打印 按钮时,应用程序将打开一个新的浏览器窗口,并在此新窗口中显示网格的内容。

要做到这一点,我们将使用一个名为 Ext.ux.grid.Printer 的插件,该插件接收要打印的 GridPanel 引用,获取 Store 上的信息,从这些内容生成 HTML,并在新窗口中显示信息。

注意

GridPrinter 插件是一个可在 github.com/loiane/extjs4-ux-gridprinter 获取的第三方插件。此插件只会打印 GridPanel Store 上可用的信息,这意味着如果你使用分页工具栏,插件将只生成当前页的 HTML。该插件还支持 RowExpander 插件。请随时为此插件(或任何其他 Ext JS 插件)做出贡献,这样我们就可以帮助 Ext JS 社区的发展!它与 Ext JS 4 和 5 兼容。

要安装插件,我们将获取 ux 文件夹的内容,并将其放置在 app/ux 文件夹内。由于 Ext JS 也通过带有命名空间 Ext.ux 的原生 SDK 提供了一些插件,我们将把插件从 Ext.ux.grid.Printer 重命名为 Packt.ux.grid.Printer 以避免冲突(你可以在 Printer.js 文件中搜索出现并替换它)。这样,插件将成为应用程序的一部分。以下截图展示了安装插件后项目结构将如何看起来:

使用 GridPrinter 插件打印 GridPanel 内容

安装插件后,我们只需将其添加到 FilmsControllerrequires 声明中:

requires: [
    // other requires here
    Packt.ux.grid.Printer'
]

当用户点击 打印 按钮时,控制器将执行以下方法:

onPrint: function(button, e, options) {
    var printer = Packt.ux.grid.Printer;
    printer.printAutomatically = false;
    printer.print(this.lookupReference('filmsGrid'));
},

printAutomatically 属性表示你希望自动显示打印窗口。如果设置为 false,则插件将显示打印窗口,然后,如果用户想要打印它,他们需要转到浏览器的菜单并选择 打印 (Ctrl + P)。

要使插件工作,我们需要将 GridPanel 引用传递给 print 方法。在这种情况下,我们可以获取 Films GridPanel 引用。

当我们执行代码时,我们将得到以下输出:

使用 GridPrinter 插件打印 GridPanel 内容

创建按电影类别划分的销售图表

Ext JS 提供了一套出色的可视化图表,我们可以实现,用户喜欢这样的事物。因此,我们将使用三个不同的系列(饼图、柱图和条形图)来实现图表,用户可以看到按电影类别销售的图表

以下是在本主题结束时我们将得到的最终结果的截图。正如我们在以下截图中所见,我们有一个图表。在其上方,我们有一个包含两个按钮的工具栏:更改图表类型,用户将能够将图表系列从饼图更改为柱图条形图,以及下载图表按钮,用户将能够以下列格式下载图表:作为图像下载作为 PDF 下载。以下是我们要讨论的截图:

创建按电影类别销售的图表

Ext JS 5 图表和术语

在我们开始编码之前,让我们了解一下 Ext JS 图表是如何工作的。Ext JS 4 通过利用 HTML5 画布和 SVG 功能引入了出色的图表功能。然而,在 Ext JS 5 中,Ext JS 4 中引入的图表已弃用。Ext JS 5 引入了一个新的 Sencha Charts 包,它来自 Sencha Touch,并内置了对触摸的支持,这意味着我们可以使用触摸手势与图表进行交互。

你可能会问,为什么我对图表中的触摸支持感兴趣?有许多公司希望在平板电脑上使用与触摸设备具有相同功能的相同应用程序,而不需要为触摸设备开发新应用程序。Ext JS 5 提供了这种功能。我们将在稍后讨论更多关于触摸支持的内容。

Sencha Charts 支持三种类型的图表:

  • 笛卡尔图表:这代表使用笛卡尔坐标的图表。笛卡尔图表有两个方向,x 方向和 y 方向。系列和轴沿着这些方向进行协调。默认情况下,x 方向是水平的,y 方向是垂直的(方向也可以翻转)。

  • 极坐标图表:这代表使用极坐标的图表。极坐标图表有两个轴:一个角度轴(即一个圆)和一个径向轴(从圆心到圆边的直线)。角度轴通常是类别轴,而径向轴通常是数值轴。

  • 空间填充图表:这创建了一个填充整个图表区域的图表,例如,一个仪表或树状图图表。

一个图表由图例系列交互主题组成,可以从 Store 加载数据,如下面的图像所示:

Ext JS 5 图表和术语

系列包含关于数据如何在图表中渲染的逻辑。系列可以是饼图折线图条形图柱图等等。

负责根据数据类型渲染图表轴。有三种类型的轴:numericcategorytimenumeric 类型用于渲染数值,category 用于渲染有限集的数据(例如,一年的月份名称),而 time 用于渲染表示时间的数值。

图例负责显示图表的图例框。

Sencha Charts 也支持交互。可用的交互如下所示:

  • 十字准线:这允许用户在图表的特定点上获得精确的值。这些值是通过在图表上单指拖动来获得的。

  • 交叉缩放:这允许用户在图表的选定区域进行缩放。

  • 项目高亮:这允许用户在图表中突出显示系列项。

  • 项目信息:这允许在弹出面板中显示关于系列数据点的详细信息。

  • 平移/缩放:这允许用户通过平移或缩放来导航一个或多个图表轴的数据。

  • 旋转:这允许用户围绕图表的中心点旋转极坐标图。对于 3D 饼图,还有一个特殊的旋转交互。

注意

通过深入了解 Charts Kitchen Sink 示例(dev.sencha.com/ext/5.1.0/examples/kitchensink/?charts=true)和文档中的 charts 包(docs.sencha.com/extjs/5.0/5.0.0-apidocs/#!/api/Ext.chart.AbstractChart)可以获得有关图表的更多信息。

将 Sencha Charts 添加到项目中

一个非常重要的细节:Sencha Charts 可以通过一个包在 Ext JS 5 应用程序中使用,这意味着图表的源代码不会自动对像网格、表单和其他组件这样的应用程序可用。Sencha 应用程序中的包与 Ruby 中的 gems 或 Java 中的 JARs 有类似的概念。

要将 Sencha Charts 添加到我们的项目中,我们需要打开位于应用程序根目录中的 app.json 文件。大约在第 34 行,我们应该找到 requires 声明。我们需要将 sencha-charts 添加到 requires 中。它看起来如下所示:

"requires": [
    "sencha-charts"
],

在此更改之后,如果我们开始在项目中使用图表,我们的代码应该可以工作。

注意

我们不能忘记在终端应用程序中执行 sencha app watch

此外,这里是我们将在 menu 表中进行的最后更新,以便在应用程序菜单中看到报告选项:

UPDATE `sakila`.`menu` SET `className`='salesfilmcategory' WHERE `id`='13';

在 ViewModel 内部创建 Store

让我们回到我们的代码,开始实现一些图表。Store 将为图表提供数据。无论我们想要创建饼图、柱状图还是条形图,我们都需要一个 Store 来提供我们想要显示的信息。

由于我们将创建图表,我们需要创建一个 Store,它将保存专门用于图表的数据集合,这意味着它不会在应用程序的任何其他地方使用。因此,我们可以在 ViewModel 中直接创建它。所以我们将创建一个名为reports的新包,位于app/view文件夹中,我们将放置这个主题的文件。我们将开始创建 ViewModel,如下所示:

Ext.define('Packt.view.reports.SalesFilmCategoryModel', {
    extend: 'Ext.app.ViewModel',

    alias: 'viewmodel.sales-film-category',

    stores: {
        salesFilmCategory: {
            fields: [                  // #1
                {name: 'category'},
                {name: 'total_sales'}
            ],
            autoLoad: true,
            proxy: {                  // #2
                type: 'ajax',
                url: 'php/reports/salesFilmCategory.php',
                reader: {
                    type: 'json',
                    rootProperty: 'data'
                }
            }
        }
    }
});

对于这个 Store,我们不会声明一个 Model;我们将在它上面直接声明其fields#1)。由于这个 Store 将仅用于图表,因此没有必要为它创建一个特定的 Model,因为我们不打算稍后重用它。

我们还将声明带有ajaxurlreader详细信息的proxy#2)。我们之前创建的大多数模型都是包含proxyschema的一部分。这次,我们没有这方面的信息,因此我们需要声明它。

在服务器端,我们可以从 Sakila 数据库的sales_by_film_category视图中查询将用于图表的数据,如下所示:

SELECT * FROM sales_by_film_category

饼图

现在我们能够从服务器检索所需的信息,让我们开始实现图表。首先,我们将开发饼图,如下所示:

Ext.define('Packt.view.reports.SalesFilmCategoryPie', {
    extend: 'Ext.chart.PolarChart',      //#1
    alias: 'widget.salesfilmcategorypie',

    legend: {
        docked: 'left'   //#2
    },
    interactions: ['rotate', 'itemhighlight'], //#3

    bind: '{salesFilmCategory}', //#4
    insetPadding: 40,          
    innerPadding: 20,
    series: {
        type: 'pie',       //#5
        highlight: true,
        donut: 20,         //#6
        distortion: 0.6,
        style: {           //#7
            strokeStyle: 'white',
            opacity: 0.90
        },
        label: {
            field: 'category',   //#8
            display: 'rotate'
        },
        tooltip: {               //#9
            trackMouse: true,
            renderer: function(storeItem, item) {
                this.setHtml(storeItem.get('category') + ': ' 
                    + storeItem.get('total_sales'));
            }
        },
        xField: 'total_sales'  //#10
    }
});

让我们回顾一下前面代码中最重要的一部分;首先,我们需要扩展PolarChart类(#1),因为我们想要实现一个带有pie系列的图表(#5)。

接下来,我们将向此图表添加legend,并将其停靠在left#2)。我们还将为此图表添加一些交互(#3)。用户将能够旋转并突出显示pie的切片。我们还将绑定在 ViewModel 中声明的 Store(#4)。

接下来是series配置,它定义了我们正在实现哪种类型的图表(#5),在这个例子中是饼图。饼图需要一个字段来执行求和并计算每个部分的分数。我们只有两个字段,而total_sales#10)是数值字段,因此我们将使用这个字段。donut配置(#6)设置甜甜圈的半径。实际上,这个图表是一个甜甜圈图(因为饼图的中间有一个洞)。

我们还可以为我们的图表设置样式(#7)。在这个例子中,样式将添加一些分隔图表每个切片的白色线条。

在每个切片内部,我们还想显示其电影类别,这样我们就可以轻松识别它。我们可以通过向series添加label配置(#8)来实现这一点。

tooltip配置中,我们可以定义是否要显示快速提示(#9)。在这种情况下,我们希望 Ext JS 跟踪鼠标的移动,如果用户将鼠标悬停在图表的任何项目上,Ext JS 将显示一个包含category名称和total_sales数字的提示。

注意

在图表中也可以定义主题。我们可以通过在这个类中添加theme配置来实现这一点。我们可以设置的值包括:'green''sky''red''purple''blue''yellow'——从'category1''category6'——以及带有'-gradients'后缀的提到的主题名称(例如'green-gradients'等)。

3D 柱状图

由于我们可以更改图表类型,因此我们还将实现一个看起来如下截图的柱状图:

3D 柱状图

因此,让我们动手编写以下代码:

Ext.define('Packt.view.reports.SalesFilmCategoryColumn', {
    extend: 'Ext.chart.CartesianChart',  //#1
    alias: 'widget.salesfilmcategorycol',

    bind: '{salesFilmCategory}', //#2

    insetPadding: {
        top: 40,
        bottom: 40,
        left: 20,
        right: 40
    },
    interactions: 'itemhighlight', //#3

    //axes
    //series
});

柱状图从CartesianChart类(#1)扩展,因为我们想显示带有x轴和y轴的图表。我们还将使用在饼图中使用的相同存储(#2),并且用户也将能够突出显示(#3)此图表中的柱状图。

让我们看看以下代码中的axes声明:

axes: [{
    type: 'numeric',   //#4
    position: 'left',
    fields: ['total_sales'],
    label: {
        renderer: Ext.util.Format.numberRenderer('0,0')
    },
    titleMargin: 20,
    title: {
        text: 'Total Sales',
        fontSize: 14
    },
    grid: true,
    minimum: 0
}, {
    type: 'category', //#5
    position: 'bottom',
    fields: ['category'],
    titleMargin: 20,
    title: {
        text: 'Film Category',
        fontSize: 14
    }
}],

在柱状图中,我们有两个x轴将显示类别#5),它将被放置在底部,而y轴将显示在右侧左侧(在这个例子中,我们选择了左侧)显示的数值(#4)。在条形图中,我们只需交换轴。类别值成为y轴,而数值成为x轴。

让我们来看看series配置以完成图表的配置:

series: [{
    type: 'bar3d', //#6
    highlight: true,
    style: {
        minGapWidth: 20
    },
    label: {
        display: 'insideEnd',
        'text-anchor': 'middle',
        field: 'total_sales',     //#7
        renderer: Ext.util.Format.numberRenderer('0'),
        orientation: 'vertical',
        color: '#333'
    },
    xField: 'category',   //#8
    yField: 'total_sales' //#9
}]

在这个例子中,我们正在实现一个 3D 柱状图(#6),它也可以用作 3D 条形图。如果我们想实现普通图表,系列类型将是'bar'。如前所述,柱状图和条形图非常相似;只是x轴和y轴的配置被交换了。如果我们想显示标签#7),我们也可以配置一个。

重要的是要注意xField#8)与类别轴(#5)匹配,而yField#9)与数值轴(垂直/左侧位置——#4)匹配。

条形图代码与柱状图代码完全相同,只是有一点小变化。我们需要反转Axis类别将变为左侧数值将变为底部),xField(将变为total_sales而不是category),以及yField(将变为category而不是total_sales)。

条形图将看起来如下:

3D 柱状图

图表面板

由于我们想显示一个面板并给用户提供更改图表类型的机会,我们将创建一个面板并使用 Card 布局。为了刷新我们的记忆,Card 布局主要用于向导,以及当我们有多个项目但只想一次显示一个时。当前显示的项目使用 FitLayout。

因此,让我们创建一个图表面板,如下所示:

Ext.define('Packt.view.reports.SalesFilmCategory', {
    extend: 'Ext.panel.Panel',
    alias: 'widget.salesfilmcategory',

    requires: [
        'Packt.view.reports.SalesFilmCategoryModel',
        'Packt.view.reports.SalesFilmCategoryController',
        'Packt.view.reports.SalesFilmCategoryPie',
        'Packt.view.reports.SalesFilmCategoryColumn',
        'Packt.view.reports.SalesFilmCategoryBar',
        'Packt.util.Glyphs'
    ],

    controller: 'sales-film-category', //#1
    viewModel: {
        type: 'sales-film-category'   //#2
    },

    layout: 'card',
    activeItem: 0,

    items: [{
        xtype: 'salesfilmcategorypie' //#3
    },{
        xtype: 'salesfilmcategorycol' //#4
    },{
        xtype: 'salesfilmcategorybar' //#5
    }],

    dockedItems: [{
        xtype: 'toolbar',
        flex: 1,
        dock: 'top',
        items: [
            //items of toolbar here #6
        ]
    }]
});

因此,我们需要声明panel并声明我们创建的每个图表作为项。因此,我们可以将饼图(#3)、柱状图(#4)和条形图(#5)声明为按电影类别销售面板的项。默认情况下,项0(即第一个项——饼图)将是当图表面板渲染时默认显示的项。

我们也不能忘记声明 ViewModel(#2)和视图控制器(#1),我们将创建下一个。

我们提到的所有类都是xtype,其中(#1#5)都在requires声明中。

接下来,我们可以声明一个包含按钮和菜单的工具栏,以便用户可以选择图表类型下载类型。我们将在前一段代码中的注释位置(#6)添加以下代码:

{
    text: 'Change Chart Type',
    glyph: Packt.util.Glyphs.getGlyph('menuReports'),
    menu: {
        xtype: 'menu',
        defaults: {
            listeners: {
                click: 'onChangeChart' //#7
            }
        },
        items: [
            {
                xtype: 'menuitem',
                text: 'Pie',
                itemId: 'pie',  //#8
                glyph: Packt.util.Glyphs.getGlyph('chartPie')
            },
            {
                xtype: 'menuitem',
                text: 'Column',
                itemId: 'column',  //#9
                glyph: Packt.util.Glyphs.getGlyph('chartBar')
            },
            {
                xtype: 'menuitem',
                text: 'Bar',
                itemId: 'bar',  //#10
                glyph: Packt.util.Glyphs.getGlyph('chartColumn')
            }
        ]
    }
},

对于前述菜单,所有菜单项都有相同的listener声明(#7)。为了避免三次重复相同的代码,我们将在视图控制器中只声明一次。为了帮助我们识别哪个菜单项触发了事件,我们还将为每行#8#9#10上的每个菜单项声明itemId。前述代码的输出显示在以下屏幕截图中:

图表面板

作为工具栏的第二个item,我们有下载图表按钮。遵循与更改图表类型按钮相同的操作,下载图表按钮也有一个包含两个菜单项的菜单,每个下载类型一个,如下所示:

{
    text: 'Download Chart',
    glyph: Packt.util.Glyphs.getGlyph('download'),
    menu: {
        xtype: 'menu',
        defaults: {
            listeners: {
                click: 'onChartDownload' //#11
            }
        },
        items: [
            {
                xtype: 'menuitem',
                text: 'Download as Image',
                itemId: 'png',
                glyph: Packt.util.Glyphs.getGlyph('image')
            },
            {
                xtype: 'menuitem',
                text: 'Download as PDF',
                itemId: 'pdf',
                glyph: Packt.util.Glyphs.getGlyph('pdf')
            }
        ]
    }
}

我们将在视图控制器(ViewController)中为这个菜单设置listener#11)。前述代码的输出如下:

图表面板

视图控制器(ViewController)

在我们开发需要实现以结束本章的两个方法之前,让我们声明reports模块的ViewController结构:

Ext.define('Packt.view.reports.SalesFilmCategoryController', {
    extend: 'Ext.app.ViewController',

    alias: 'controller.sales-film-category',

    //methods here
});

接下来,我们将看到如何开发onChangeChartonChartDownload方法。

更改图表类型

由于用户可以通过从菜单中选择选项来更改图表类型,我们将开发以下方法:

onChangeChart: function(item, e, options) {
    var panel = this.getView(); // #1

    if (item.itemId == 'pie'){
        panel.getLayout().setActiveItem(0); // #2
    } else if (item.itemId == 'column'){
        panel.getLayout().setActiveItem(1); // #3
    } else if (item.itemId == 'bar'){
        panel.getLayout().setActiveItem(2); // #4
    }
}

首先,我们需要获取图表面板。我们可以通过从视图控制器(ViewController)调用getView方法(#1)简单地检索它。

由于被点击的菜单项触发了事件 click,该方法接收的第一个参数是该项本身。我们可以获取其itemId属性来比较用户点击了哪个itemId,并根据用户选择的选项相应地设置ActiveItem#2#3#4)。

setActiveItem方法来自卡片布局。从视图(View)中,我们可以获取布局,它将返回一个卡片布局的实例,并且该方法将可用。

将图表导出为图像(PNG 或 JPEG)

onChartDownload方法中,我们将遵循与更改图表类型菜单项相同的逻辑。但在这个情况下,我们希望将图表保存为图像(PNG)或 PDF 文件。以下是我们的操作步骤:

onChartDownload: function(item, e, options) {
    var panel = this.getView();
    var chart = panel.getLayout().getActiveItem(); //#1

    if (item.itemId == 'png'){
        Ext.MessageBox.confirm('Confirm Download', 
            'Would you like to download the chart as Image?', function(choice){
            if(choice == 'yes'){
                chart.download({   //#2
                    format: 'png', 
                    filename: 'SalesXFilmCategory'
                });
            }
        });
    } else if (item.itemId == 'pdf'){
        Ext.MessageBox.confirm('Confirm Download', 
            'Would you like to download the chart as PDF?', function(choice){
            if(choice == 'yes'){
                chart.download({  //#3
                    format: 'pdf',
                    filename: 'SalesXFilmCategory',
                    pdf: {
                        format: 'A4',
                        orientation: 'landscape',
                        border: '1cm'
                    }
                });
            }
        });
    }
}

Chart类已经有一个名为download的方法,我们可以使用它以不同格式下载图表。这是来自 Ext JS 的本地功能。

因此,首先,我们需要获取Chart类的引用,这可以通过图表面板的ActiveItem#1)来获取。

然后,根据用户的选择,我们首先会询问用户是否真的想要以特定格式下载图表,如果是,我们将请求 Ext JS 生成文件。因此,如果用户选择以 PNG(#2)或 PDF(#3)格式下载,我们只需从图表引用调用download方法,传递用户选择的特定类型。在这种情况下,应用程序将向http://svg.sencha.io发送请求,并开始下载。

根据文档,我们可以向download方法传递一个包含一些选项的对象配置:

  • url: 这是要发送数据的 URL。默认情况下为 Sencha IO。

  • format: 这是导出图像的格式。默认情况下为'png'。可能的值是pngpdfjpeggif

  • width: 这是一个发送到服务器以配置图像宽度的值。默认情况下,在 Sencha IO 服务器上为自然图像宽度。

  • height: 这是一个发送到服务器以配置图像高度的值。默认情况下,在 Sencha IO 服务器上为自然图像高度。

  • filename: 这是下载图像的文件名。默认情况下,在 Sencha IO 服务器上为'chart'config.format用作文件名扩展名。

  • pdf: 这是一个 PDF 特定的选项。此配置仅在config.format设置为'pdf'时使用。请参阅文档以获取更多详细信息。

  • jpeg: 这是一个 JPEG 特定的选项。此配置仅在config.format设置为'jpeg'时使用。请参阅文档以获取更多详细信息。

注意

如果你计划在支持触摸的设备上运行应用程序,建议你使用preview方法而不是download方法。preview方法会打开一个包含图表图像的弹出窗口,在这种情况下,用户可以使用设备的本地功能来保存图像。

以下截图是从选择将图表保存为 PNG 时生成的图像:

导出图表为图像(PNG 或 JPEG)

如果你想在你的服务器上生成图像或 PDF,你可以指定数据将要发送到的url。在服务器上,你可以检索一个名为data的 POST 变量。该变量包含以下内容(它是一个Base64图像),可以在服务器上对其进行操作以返回图像、PDF 或其他所需格式:

导出图表为图像(PNG 或 JPEG)

摘要

在本章中,我们学习了如何将 GridPanel 的内容导出为 PDF、Excel,以及一个打印友好的页面。

我们还学习了如何创建不同类型的图表,使用单个组件并更改其活动项,以及如何使用 Ext JS 原生功能将图表导出为图像或 PDF。

在下一章中,我们将学习如何测试应用程序,如何启用触摸支持(这样我们就可以从平板电脑或智能手机上执行应用程序),以及如何启用路由。

第十章:路由、触摸支持和调试

在本章中,我们将执行在自定义主题和创建应用程序的生产构建之前的最后几步。我们将涵盖一些不同的主题,例如在我们的应用程序中启用路由、关于响应式设计和 Ext JS 的快速概述、触摸支持、调试 Ext JS 应用程序以及关于测试的快速概述。

因此,在本章中,我们将涵盖以下内容:

  • Ext JS 路由

  • 响应式设计和触摸支持

  • 将 Ext JS 项目转换为移动应用程序

  • 调试 Ext JS 应用程序

  • 测试 Ext JS 应用程序的工具

  • 有用的工具

  • 如何找到额外的开源插件

Ext JS 路由

路由是 Ext JS 5 中引入的一项功能,它使得在应用程序中使用Ext.util.History类处理历史记录的过程变得更加容易。

在一个普通网站上,用户通过点击链接或填写表单来导航到不同的页面。然而,在单页应用程序中,用户的交互不会加载新页面。相反,它是在单个页面内处理的,组件对这种交互做出反应。那么我们如何仍然允许用户使用浏览器的后退和前进按钮呢?使用路由允许用户通过将哈希令牌映射到控制器方法来使用这种功能。

例如,我们有一个用于管理电影信息的屏幕。使用路由,我们可以允许用户访问此屏幕(如果用户有适当的权限)并通过访问https://localhost/masteringextjs/#films/3自动选择电影网格的特定行。当用户访问此链接时,我们可以指示应用程序打开电影标签并选择 ID 为3的电影行。

要在我们的应用程序中启用路由,我们将使用由 Sencha Cmd 在创建应用程序时自动创建的Root Controller。

默认令牌

我们将首先启用默认令牌。当我们的应用程序启动时,它将重定向到#home哈希令牌。为此,我们将向Application.js文件中添加以下代码:

defaultToken : 'home',

然后,在Root Controller 内部,我们将监听此哈希并重定向到onHome方法,如下所示:

routes : {
    'home' : 'onHome'
},

onHome方法内部,我们希望激活Main Panel的第一个标签(首页)。使用以下代码来完成此操作:

onHome : function() {
    var mainPanel = this.getMainPanel(); //#1
    if (mainPanel){
        mainPanel.setActiveTab(0);
    }
},

我们使用getMain方法(#1),它指的是 Controller 的ref。我们需要声明它。我们将在init方法中声明ref配置:

init: function() {
    this.addRef([{
        ref: 'mainPanel',
        selector: 'mainpanel'
    }]);
    this.callParent();
}

以编程方式加载 Controller

在本书的开头,你了解到当应用程序加载时(MVC)会加载控制器。由于我们的应用程序有一个登录屏幕,我们不想在应用程序加载时启用路由。我们只想在用户登录后启用路由。Root Controller 将在应用程序加载时加载,我们不希望发生这种情况。

Application.js中,我们将注释掉Root Controller:

controllers: [
    //'Root',
    'Menu',
    'StaticData'
],

Main视图加载时,我们想要初始化Root控制器。所以,我们将在main.MainControllerinit方法中init``Root控制器:

init: function() {
    Packt.app.createController('Root');
},

这就是我们在 Ext JS 中程序化创建控制器的方法。只有在Main视图加载后,路由器才会启用。我们可以在MainController内部放置所有路由,但它将与MainController中已有的现有代码共存,并且在未来维护此代码可能会有些困难。这也是我们需要做出的设计决策之一:我们是否将所有路由都放在单个控制器中,还是将代码分开?请随意组织代码,使其符合您的需求。在大型应用程序中,在单个控制器中维护许多组织良好的 hash 标记可能会有点困难。

到目前为止,我们有的代码,当我们渲染https://localhost/masteringextjs应用程序时,它将被自动重定向到https://localhost/masteringextjs/#home

处理路由

Root控制器routes配置中,我们将处理我们应用程序的可能路由。我们将使用为我们应用程序创建的xtypes。好事是我们一直在跟踪它们在我们数据库的menu表中的className列!

以下是我们应用程序中可能存在的 hash 标记:useractorsgridcategoriesgridlanguagesgridcitiesgridcountriesgridfilmssalesfilmcategory。我们可以为它们中的每一个定义一个方法,就像我们为home标记开发的那样。但是,当用户访问任何这些标记并在相应的标签页中打开时,应该做什么。所以,我们想要在单个方法中处理多个标记。这就是我们将在Root控制器的routes配置中添加的代码:

'user|actorsgrid|categoriesgrid|languagesgrid|citiesgrid|countriesgrid|films|salesfilmcategory': {
    before: 'onBeforeRoute',
    action: 'onRoute'
}

当我们想要在同一个方法中处理多个标记时,我们可以使用|来分隔它们。注意,在前面的代码中,我们声明了两个方法:onBeforeRouteonRoute。我们可能想要检查用户是否有权限访问屏幕(毕竟,什么阻止了一个聪明的用户试图通过路由访问用户没有权限的屏幕呢?)。所以,我们可以在以下代码中处理它(然而,这不一定是最安全的保护应用程序的方法):

onBeforeRoute: function(action){
    var hash = Ext.util.History.getToken(); //#1

    Ext.Ajax.request({
        url     : 'php/security/verifyEntitlement.php',
        params  : {
            module : hash
        },
        success : function(conn, response, options, eOpts) {

            var result = Packt.util.Util.decodeJSON(conn.responseText);

            if (result.success) {
                action.resume();   //#2
            } else {
                Packt.util.Util.showErrorMsg(result.msg);
                action.stop();     //#3
            }
        },
        failure : function(conn, response, options, eOpts) {
            Packt.util.Util.showErrorMsg(conn.responseText);
            action.stop();         //#4
        }
    });
},

由于我们在这里使用的是通用代码,并且我们希望将用户试图访问服务器的className参数发送到服务器,我们可以使用第#1行中的代码来检索它。before动作方法只接收一个参数,即action参数。根据结果,我们可以恢复它(#2),这意味着用户可以访问屏幕——onRoute方法将被执行。或者,我们可以停止动作(#3#4),这意味着onRoute方法将不会执行。

让我们看看onRoute方法:

onRoute: function(){
    var me = this,
        hash = Ext.util.History.getToken(),
        main = me.getMain(); //#5

    me.locateMenuItem(main, hash); //#6
},

此方法将调用locateMenuItem#6)方法,传递mainmenu引用(#5):

{
    ref: 'main',
    selector: '[xtype=mainmenu]'
}

重构菜单代码

让我们来看看locateMenuItem代码:

locateMenuItem: function(mainMenu, hash){
    var me = this,
        root, node;
    Ext.each(mainMenu.items.items, function(tree){
        if (tree.getXType() === 'menutree'){
            root = tree.getRootNode();
            node = root.findChild('className', hash);
            if (node){
                me.openTab(node); //#1
                return;
            }
        }
    });
},

之前提到的方正在搜索我们为应用程序的菜单创建的每个menutree中的节点,以匹配路由的哈希。如果我们找到节点,我们将调用openTab方法(#1):

openTab: function(record){
    var mainPanel = this.getMainPanel();

    var newTab = mainPanel.items.findBy(
        function (tab){
            return tab.title === record.get('text');
        });

    if (!newTab){
        newTab = mainPanel.add({
            xtype: record.get('className'),
            glyph: record.get('glyph') + '@FontAwesome',
            title: record.get('text'),
            closable: true
        });
    }

    mainPanel.setActiveTab(newTab);
},

如果我们查看Menu控制器中onTreePanelItemClick方法的代码,我们将注意到它与openTab方法完全相同。尽管我们将在本书的末尾处理路由,但最好在我们开始开发应用程序时就处理它们。所以,如果你打算使用路由,确保路由是设计的一部分,因为如果你在应用程序开发之后决定实现它,可能需要进行一些代码更改。

onTreePanelItemClick方法现在将有以下代码:

onTreePanelItemClick: function(view, record, item, index, event, options){
    this.redirectTo(record.get('className'));
},

当用户点击我们实现的菜单的Tree中的Node时,它将重定向到NodeclassName参数的哈希,并且Root控制器将处理打开标签页。

处理不匹配的路由

如果用户尝试访问应用程序中未定义的路由,我们也可以执行一些代码。在Root控制器中,我们可以添加以下代码:

listen : {
    controller : {
        '*': {
            unmatchedroute: 'onUnmatchedRoute'
        }
    }
},

我们可以向用户显示错误消息,如下所示:

onUnmatchedRoute : function(hash) {
    Packt.util.Util.showErrorMsg('Hash does not exist!');
}

处理参数

现在,让我们开发更复杂的路由处理。对于Films屏幕,假设我们希望用户能够使用哈希令牌从 Films 网格中选择一行,如下面的截图所示:

处理参数

我们可以定义以下路由:

'films/:id' : {
    action: 'onFilmSelect',
    before: 'onBeforeFilmSelect',
    conditions : {
        ':id' : '([0-9]+)'
    }
}

这意味着用户可以尝试访问格式为https://localhost/masteringextjs/#films/2的 URL。如果用户尝试访问https://localhost/masteringextjs/#films/ace,则由于id参数的条件,它是不有效的——它需要是一个数值。这意味着我们也可以定义正则表达式来验证哈希令牌的参数。

在我们执行onFilmSelect之前,我们想做一些事情:

onBeforeFilmSelect: function(id, action){

    var me = this,
        main = me.getMain();

    this.locateMenuItem(this.getMain(),'films'); //#1

    var record = this.getFilmsGrid().getStore().findRecord('film_id', id);
    if(record) {
        action.resume();
    }
    else {
        action.stop();
    }
},

我们需要打开Films屏幕(#1)并检查用户想要选择的记录是否存在于 Store 中。如果结果是正的,继续执行;如果不是,停止执行。

filmsGrid的引用在这里给出:

{
    ref: 'filmsGrid',
    selector: '[xtype=films-grid]'
}

最后,onFilmSelect方法的引用在这里给出:

onFilmSelect: function(id){
    this.getFilmsGrid().fireEvent('selectfilm', id);
}

我们将触发films-gridselectfilm事件,如下面的代码所示;这需要在FilmsGrid类内部编写一些新代码:

listeners: {
    itemclick: 'onItemClick',
    selectfilm: 'onFilmSelect'
}

我们将在FilmsController类中处理监听器,如下面的代码所示:

onFilmSelect: function(id){
    var me = this,
        grid = me.lookupReference('filmsGrid'),
        store = me.getStore('films'),
        record = store.findRecord( 'film_id', id );

    if (record){
        grid.getSelectionModel().select(record);
    }
},

在前面的代码中,我们正在通过film_id查找记录并在FilmsGrid中选择它。

当用户点击网格的行时,将触发onItemClick方法:

onItemClick: function( view, record, item, index, e, eOpts ) {
    this.redirectTo('films/' + record.get('film_id'));
}

我们将简单地重定向请求到Route控制器,以便它可以处理我们在此主题中开发的代码所进行的选取。

注意

如前所述,在某些应用程序中,路由可能会变得非常复杂,因此最好的做法是从开发初期就开始处理它们。更多详情,请参阅 Sencha 关于路由的指南和文档。

使用响应式设计插件

Ext JS 5 引入了另一个新功能,即开发响应式应用程序的选项。移动设备已经成为我们生活的一部分。我们基本上在口袋里就有一个电脑。如今,在用户需求文档中将平板或移动设备兼容性作为一项内容列出是非常常见的。幸运的是,Ext JS 5 提供了良好的支持,并允许我们无需太多努力就能实现这一需求。

Ext JS 5 引入了响应式插件和混合。插件可用于任何组件,混合可用于任何其他类。此插件通过控制responsiveConfig动态响应屏幕尺寸和方向的变化。

例如,让我们在我们的项目中快速做一个示例。如果屏幕宽度小于 768 像素,并且屏幕处于tall模式,我们将隐藏应用程序菜单并显示一个新按钮,该按钮将显示如下截图所示的菜单:

使用响应式设计插件

Packt.view.main.Main类中,我们将向west区域添加响应式插件。代码如下所示:

{
    xtype: 'mainmenu',
    region: 'west',
    plugins: 'responsive',
    responsiveConfig: {
        'width < 768 && tall': {
            visible: false
        },
        'width >= 768': {
            visible: true
        }
    }
}

responsiveConfig内部,我们可以添加一些条件并相应地设置组件的配置。我们可以更改布局,渲染不同的组件,并在应用程序中完成所需的一切。仅使用前面的代码,如果我们执行应用程序并减小浏览器宽度,当满足条件时,我们将看到菜单会自动隐藏。这真是太棒了!

Packt.view.main.Header中,我们还将添加一个新的组件,如下所示:

{
    xtype: 'tbfill'
},{
 xtype: 'responsive-mainmenu'
},{
    xtype: 'translation'
},

此组件的代码如下所示:

Ext.define('Packt.view.main.ResponsiveMenuButton', {
    extend: 'Ext.button.Split',
    xtype: 'responsive-mainmenu',

    requires: [
        'Packt.view.main.MainModel'
    ],

    text: 'Menu',

    plugins: 'responsive',
    responsiveConfig: {
        'width < 768 && tall': {
            visible: true
        },
        'width >= 768': {
            visible: false
        }
    },

    menu: {
        xtype: 'menu',
        items: [{
            xtype: 'mainmenu'
        }]
    }
});

我们正在重用本书开头开发的菜单。当然,我们可以开发一个更用户友好的 UX,但在这里我们只是关注功能,进行快速测试。

如果我们需要处理响应式设计,Google Chrome 有一个非常棒的功能。它能够模拟项目在不同设备上的显示效果,以便查看它们的外观。在 Google 开发者工具中,点击下面的截图所示的移动设备图标,开始您的探险之旅:

使用响应式设计插件

注意

关于 Ext JS 5 和响应式设计的更多信息,请阅读www.sencha.com/blog/designing-responsive-applications-with-ext-js并检查此示例的源代码goo.gl/odce6j。Firefox 在其开发者工具栏中也有一个移动视图。转到工具 | Web 开发者 | 响应式设计视图

启用触摸支持

在本节中,我们将简要讨论响应式设计。由于这不是本书的主要内容,我们将实现一个非常简单的示例,以让我们了解如果我们需要使用 Ext JS 开发响应式应用程序时需要做什么。谈到响应式设计,我们知道 Ext JS 非常适合开发桌面应用程序(将在台式计算机或笔记本电脑上执行),但手机现在已经成为我们生活中不可或缺的一部分。我们将找出在桌面和移动设备上运行相同应用程序的方法。我们还将讨论如何在我们的应用程序中启用触摸支持。

在桌面设备和移动设备上运行应用程序的主要区别在于事件,以及其他细节。在桌面上,当用户点击按钮时,我们监听click事件。在触摸式移动设备上,没有click事件;存在的是tap事件,因为我们是在触摸屏幕而不是使用鼠标。

另一个细节是组件的大小。Ext JS 经典主题确实很漂亮,但触摸屏上太小了。在本书的整个过程中,我们一直在使用Neptune主题,这是我们在使用 Sencha Cmd 创建应用程序时设置的默认主题。Neptune主题的组件比classic主题大,但仍然不足以在触摸设备上使用。让我们实验一下!如果您有触摸设备,请尝试在它上面运行本书中开发的整个应用程序。如果您没有触摸设备,您不必担心;您可以使用前面主题中提到的 Google Chrome 模拟器进行此实验。

以下截图展示了在 iPad mini 上运行的应用程序:

启用触摸支持

如果我们尝试使用当前的应用程序,我们将能够使用其大部分功能。因为我们使用的是桌面主题,图标较小,所以在移动设备上可能无法 100%工作。例如,RowExpander + 按钮工作得不是很好,因为+图标对于移动设备来说太小了。

Ext JS 5 引入了专门为触摸设备设计的全新主题。有一个特殊的Neptune主题版本,还有一个特殊的Crisp主题版本(也是在 Ext JS 5 中引入的)。我们可以通过在app.js文件中更改主题来为我们的应用程序添加触摸支持:

"theme": "ext-theme-neptune-touch", //or "ext-theme-crisp-touch"

在终端中执行sencha app watch时,尝试将主题更改为之前提到的选项之一。别忘了清理浏览器的缓存,以确保您在下次刷新应用程序时获取到新的 CSS 文件版本。现在让我们再次尝试应用程序,如下所示:

启用触摸支持

注意组件之间的空间现在更大了。这是为了确保我们可以点击(触摸)组件。

我们没有触摸优化的应用。为了使其达到 100%,我们可以审查我们设置的任何大小(例如,列宽),并使用一些响应式设计技术,使应用在移动设备上看起来很棒!

注意事项

更多信息,请访问 goo.gl/VnT7bT

从 Ext JS 到移动

如果你正在开发的产品需要特殊的应用实现,尤其是针对触摸设备呢?我们不是在谈论我们在前一个主题中实现的技巧;我们是在谈论同一产品的移动应用。例如,Facebook 有桌面版本,但也为移动设备提供了应用。这可能也是你需要的。

我们想介绍 Sencha Touch,它是 Ext JS 的表亲!Sencha Touch 是市场上第一个 HTML5 移动框架。而且还有更多好消息:你不需要重写所有代码,就可以让同样的应用也适用于移动设备。

Sencha Touch 和 Ext JS 共享相同的 API。数据包,如模型、存储和框架的核心,是相同的。Sencha Touch 也使用 MVC。控制器和视图(组件)的工作方式与 Ext JS 控制器和视图非常相似。当然,最大的区别在于视图,因为网络组件与移动组件不同。然而,Sencha Touch 也提供了表单和列表,我们甚至可以找到为移动设备定制的网格组件。图表在框架之间也是共享的。

以下图表显示了使用 Sencha Touch 后我们可以重用多少代码的分析:

从 Ext JS 到移动

我们可以重用的代码量是巨大的!我们还有两种实现方式:第一种是拥有一个移动应用,用户将访问指向 Sencha Touch 部署的 URL(Sencha Touch 和服务器端代码在同一域名下)。第二种选择是将 Sencha Touch 代码运行在用户的设备上(Sencha Touch 为 iOS 和 Android 提供了原生打包,但我们也可以使用 Sencha Touch 创建原生 Blackberry 10 和 Windows Phone 8 应用),服务器端代码运行在 Web 服务器上。在这种情况下,我们可以使用 CORS (enable-cors.org/) 来实现应用和服务器端代码之间的 Ajax 通信。

注意事项

想了解更多关于 Sencha Touch 的信息,请访问 www.sencha.com/products/touch/

在移动设备上,也可以访问硬件功能,如联系人、相机、地理位置等。我们可以使用一个名为 Apache Cordova 的开源框架(或 Phonegap,它是 Apache Cordova 的实现)。Sencha Cmd 也具有支持与 Cordova 集成的命令。以下链接提供了更多关于此主题的信息:

调试 Ext JS 应用程序

调试的艺术与编程的艺术一样重要。我们通常编写我们认为一旦执行就会工作的代码,但有时这并不正确。我们编写代码,然后遇到异常或 JavaScript 错误,然后我们需要再次深入代码以查看我们哪里出了错。这是开发者工作的一部分,也是生活的一部分!

在整本书中,你了解到调试很重要,尤其是在我们学习一种更简单的方式来确定正确的 组件查询 选择器时。当使用 Ext JS 开发应用程序时,使用调试工具是强制性的。这是因为它不仅用于调试,你还将能够更多地了解框架,而且这是一项极好的学习练习。

在创建 Ext JS 应用程序时,我们始终需要提醒自己的几点:大小写敏感——LoginScreen 类与 Loginscreen 不同。小心保留字(mattsnider.com/reserved-words-in-javascript/)——你不能将它们用作命名空间、类和包的名称,或者用作变量名。检查拼写;这非常重要——有时当我们输入时,可能会多输入一个字符(大拇指指错综合症)。

如果你现在几乎已经用 JavaScript 编程了 10 年,你会知道在此之前,我们唯一的伙伴是亲爱的 alert 提示框。我们过去经常在代码中放置几个提示框,执行代码,然后查看哪个提示框没有被执行,以便我们可以找到错误所在。现在我们有我们亲爱的朋友 console。滥用 consolelogwarnerror 功能!

我们还拥有出色的调试工具!其中最重要的两个是 Google 开发者工具和 Firefox 的 Firebug!至少学会使用其中一个(它们非常相似)。

例如,让我们使用 Google 开发者工具。它包含几个标签页;在 网络 标签页上,我们可以看到如下加载的文件:

调试 Ext JS 应用程序

至于正在加载或未加载的文件,这是一个非常大的问题!简单的错误,比如类的名称(使用 MVC)、CSS 路径和 index.html 文件中的 JS,可以使用 控制台网络 选项卡进行验证。这个选项卡也非常重要,因为在某些章节中,我们验证了发送到服务器的参数。即使这是我们第一次使用 Ext JS 组件,并且我们不知道如何处理将发送到服务器的数据,我们也可以查看 网络 选项卡中的请求参数,然后更容易地读取服务器上的正确参数。当我们从服务器接收任何信息时,这也适用,例如,检查 JSON 是否符合 Ext JS 的预期。

元素 选项卡中,我们可以看到详细信息;由 Ext JS 代码生成的 HTML 代码以及应用于组件的 CSS。当我们想要应用一些自定义 CSS 并调试为什么样式没有被应用时,这非常有用。

调试 Ext JS 应用程序

当我们将鼠标移过时,与该 HTML 相关的部分会在屏幕上突出显示。我们还有 CSS脚本 选项卡。我们可以实时更改 CSS 和脚本,并实时看到应用的变化!这真是太神奇了!因此,学习如何使用调试工具非常重要。

选项卡中,我们可以以项目结构的形式获取加载的源代码。对我们来说,这个选项卡非常重要,因为它允许我们使用浏览器的调试功能来调试我们的代码。调试技术非常类似于在服务器端语言中使用的技术;我们添加断点,执行代码,然后观察和检查变量值,查看源代码的每一行发生了什么,如下所示:

调试 Ext JS 应用程序

注意

想了解更多关于 Firebug 的信息,请访问 getfirebug.com/。想了解更多关于 Google 开发者工具的信息,请访问:developers.google.com/chrome-developer-tools/developer.chrome.com/extensions/tut_debugging

当然,还有我们在第七章静态数据管理中提到的特殊附加组件:Sencha 为 Chrome 提供的附加组件和 Illumination 为 Firebug 开发者提供的附加组件。

掌握一个调试工具与掌握 Ext JS 编程的艺术同样重要。毕竟,我们不知道我们是否会有机会从头开始工作在一个项目上,或者我们需要维护其他开发者的代码。在这种情况下,知道如何调试是一项必备技能!选择你喜欢的工具,享受编码和调试的乐趣!

测试 Ext JS 应用程序

测试在开发应用程序或提供维护时是一个非常重要的部分。当我们不编写测试时,我们需要手动验证每个用例,如果我们对代码进行了任何更改,我们还需要手动重新执行所有测试。当我们需要维护代码时,情况也是如此;开发者通常只测试更改的部分,但正确的方法应该是回归测试,以查看更改是否破坏了其他任何东西。因此,花一些时间编写测试最终可能是有益的。你将花费更多的时间编写代码,但然后你将能够通过单次点击运行所有测试,并验证哪些部分出了问题,哪些部分仍在正常工作。

我们也非常习惯于在服务器端代码上执行单元测试。Java、PHP、Ruby、C# 社区提供了很多选项来执行服务器端代码的单元测试,有时我们可能会忘记测试前端代码(在这种情况下是 Ext JS)。但不用担心;有一些工具我们可以使用,以便将 Ext JS 也包含在测试中。

对于一般的 JavaScript 测试来说,一个非常受欢迎的工具是 Jasmine (jasmine.github.io/)。Jasmine 是一个用于 行为驱动开发BDD)的测试工具(en.wikipedia.org/wiki/Behavior_driven_development)。在 Ext JS 文档中,你可以找到两篇指南,解释如何使用 Jasmine 测试 Ext JS 应用程序:docs.sencha.com/extjs/4.2.0/#!/guide/testingdocs.sencha.com/extjs/4.2.0/#!/guide/testing_controllers。尽管这些指南是为 Ext JS 4.x 编写的,但它们也可以应用于 Ext JS 5。

还有一个专门为 Sencha 应用程序设计的测试框架,称为 Siesta (www.bryntum.com/products/siesta/)。Siesta 还可以用于测试一般的 JavaScript 代码,但 Siesta 的酷之处在于它提供了一个特殊的 API,这样我们就可以测试 Ext JS 应用程序,包括对用户界面组件的测试。Siesta 随带了一些优秀的示例,我们可以使用这些示例来开始编写自己的测试用例。

有用的工具

在这个主题中,我们将介绍一些可以帮助开发者实现 Ext JS 应用程序的工具。你可以在这个主题的末尾找到提到的所有工具的链接。

第一款工具是 JSLint。JSLint 是一款可以帮助你查找 JavaScript 错误并帮助你清理代码的工具。

第二款工具是 YSlow。YSlow 分析网页,并基于高性能网站的规则告诉你它们为什么运行缓慢。YSlow 是一个与流行的 Firebug 网页开发工具集成的 Firefox 插件。

Ext JS 是一个 JavaScript 框架,JavaScript 性能是许多公司关心的话题。用户在浏览器上需要加载的最小内容越好。这就是为什么使用 Sencha Cmd 进行生产构建非常重要,而不是简单地将所有应用程序文件部署到生产环境中。

Sencha Cmd 还会压缩 Ext JS CSS 文件到一个更小的 CSS 文件,我们也可以只包含我们将要真正使用的组件的 CSS(以防我们创建了一个自定义主题)。同样重要的是,在 sass/etcsass/var 文件夹中创建任何应用程序自定义 CSS,以便 CSS 也可以添加到由 Sencha Cmd 生成的主 CSS 文件中。

CSS Sprites 是另一个非常重要的主题。字体图标,如 Font Awesome,确实很棒,但有时需要使用图像图标。在这种情况下,我们可以创建一个 CSS Sprite,这涉及到创建一个包含所有图标的单个图像。在 CSS 中,我们只需一个图像,并传递一个 background-position 属性来显示我们想要的图标,如下所示:

.icon-message {
  background-image: url('mySprite.png');
  background-position: -10px -10px;
}

.icon-envolope {
  background-image: url('mySprite.png');
  background-position: -15px -15px;
}

有一些工具也可以帮助我们创建 CSS Sprites,例如 SpritePadSpriteMeCompass Sprite Generator

这里是本节中提到的所有工具的链接:

总是记住,Ext JS 是 JavaScript,因此我们还需要关注性能。通过在这个主题上发布的所有这些小贴士,一个 Ext JS 应用程序可以提高其性能。

最后但同样重要的是,Sencha 提供了两个工具:Sencha ArchitectSencha Eclipse Plugin。Sencha Architect 是一个类似于 Visual Studio 的可视化设计工具:你可以拖放元素,并可以看到应用程序的外观,以及你需要完成的整个配置都是通过 Config 面板来完成的。只有方法、函数和模板可以自由输入你喜欢的代码。Sencha Architect 的好处在于它有助于遵循所有最佳实践,并且代码组织得非常好。你还可以使用 Sencha Architect 开发所有的 Ext JS 代码,并且在服务器端,你可以继续使用你最喜爱的 IDE(Eclipse、Aptana、Visual Studio 等)。

Sencha Eclipse 插件是 Eclipse IDE 的一个插件,它启用了自动完成功能。Sencha Architect 和 Sencha Eclipse 插件都是付费工具。但你可以下载试用版进行测试,地址为www.sencha.com/products/complete/www.sencha.com/products/architect/

另一个用于开发 Sencha 应用程序的出色 IDE 是WebStorm(或IntelliJ IDEA)。WebStorm 还具备自动完成功能(如果设置正确),支持 Sass 和 Compass(Ext JS 用于主题的库),以及 JSLint 来验证 JavaScript 代码,以及其他功能。它也是一个付费工具,但你可以下载试用版进行测试,地址为www.jetbrains.com/webstorm/。本书的源代码是用 IntelliJ IDEA 编写的。

第三方组件和插件

虽然 Ext JS 提供了优秀的组件,但我们通常还希望开发自己的组件,或者可能使用其他开发者的组件。在这一点上,Ext JS 社区做得很好。许多开发者与社区分享他们自己的组件、扩展和插件。你可以从以下两个主要地方找到它们:

摘要

在本章中,你学习了如何启用路由,并且也快速概述了 Ext JS 应用程序的责任设计和触摸支持。你了解了了解如何调试 Ext JS 应用程序的重要性,以及一些可以帮助我们完成这项任务的工具。你还了解到性能非常重要,并且我们可以借助一些免费工具做更多的事情来提高我们 Ext JS 应用程序的性能。我们还列出了在哪里可以找到优秀的插件、扩展和新组件,这些我们可以在项目中使用。

在下一章中,我们将为我们的应用程序定制主题,并执行生产构建。

第十一章。为生产准备和主题

我们在前一章完成了我们的应用程序。现在,是时候创建一个漂亮的主题,为应用程序增添个人风格,并准备将其部署到生产环境中。毕竟,我们一直在开发环境中工作,当我们想要上线时,我们不能简单地部署所有文件;我们首先需要进行一些准备。因此,在本章中,我们将涵盖:

  • 创建自定义主题

  • 为生产打包应用程序

开始之前

本章我们将使用的主要工具是 Sencha Cmd。使用 Sencha Cmd,我们将能够创建自定义主题并执行生产构建。我们始终需要确保我们使用的 Sencha Cmd 版本与我们所使用的 Ext JS 版本兼容。如果你从 Sencha 网站下载了前面的 Ext JS 版本,请同时获取最新的 Sencha Cmd(它将是兼容的)。

到目前为止,这是我们在这本书中开发的内容:

开始之前

我们创建的所有代码都位于 appindex.htmlphpresources(自定义图像图标、字体和自定义区域文件)以及 sass(自定义应用程序 CSS)中。其他文件夹和文件是由 Sencha Cmd 创建的,正如你在第二章中学习的,入门

创建新主题

本章我们将执行的第一项任务是为我们项目创建一个新主题。为此,我们将使用 Sencha Cmd 和操作系统的终端应用程序。

Sencha Cmd 现在具有生成我们创建全新主题所需完整文件结构的能力。

因此,让我们一步一步地创建一个新主题。首先,在终端打开后,将目录更改为项目的根文件夹。然后,我们将使用以下命令:

sencha generate theme masteringextjs-theme

前一个命令的输出如下:

创建新主题

在这里,masteringextjs-theme 是我们主题的名称。此命令将在 packages 文件夹内创建一个以我们主题命名的新的目录,如下所示:

创建新主题

package.json 文件包含 Sencha Cmd 使用的主题的一些配置,例如主题名称、版本和依赖项。

sass 目录包含我们主题的所有 Sass 文件。在这个目录内,我们将找到三个更多的主目录:

  • var:这包含 Sass 变量。

  • src:这包含 Sass 规则和混入。这些规则和混入使用在 sass/var 目录内文件中声明的变量。

  • etc:这包含额外的实用函数和混入。

我们创建的所有文件都必须匹配我们正在样式的组件的类路径。例如,如果我们想样式化按钮组件,我们需要在文件 sass/var/button/Button.scss 内创建样式;如果我们想样式化组件面板,我们需要在文件 sass/var/panel.scss 内创建样式。

resources 文件夹包含我们的主题将使用的图像和其他静态资源。

overrides 文件夹包含所有可能用于主题化这些组件的组件 JavaScript 覆盖。

小贴士

花些时间探索 packages 文件夹内以下目录的内容,以更熟悉这种组织 Sass 文件的方式:ext-theme-classicext-theme-grayext-theme-neptune

默认情况下,我们创建的任何主题都使用 ext-theme-classic 作为基础(经典的 Ext JS 蓝色主题)。我们将更改为我们一直使用的 Neptune 主题。要更改主题基础,打开 package.json 文件并找到 extend 属性。将其值从 ext-theme-classic 更改为 ext-theme-neptunepackage.json 的内容将类似于以下内容:

{
    "name": "masteringextjs-theme",
    "type": "theme",
    "creator": "anonymous",
    "summary": "Short summary",
    "detailedDescription": "Long description of package",
    "version": "1.0.0",
    "compatVersion": "1.0.0",
    "format": "1",
    "slicer": {
        "js": [
            {
                "path": "${package.dir}/sass/example/custom.js",
                "isWidgetManifest": true
            }
        ]
    },
    "output": "${package.dir}/build",
    "local": true,
    "requires": [],
 "extend": "ext-theme-neptune"
}

我们可以将任何 Ext JS 主题作为自定义主题的基础主题。以下是一些可能的选项:

创建新主题

我们可以使用 Ext JS 中的任何主题包。这些包可以在 ext/packages 内找到。

注意

您可以从 Ext JS 示例页面尝试 Theme Viewer 示例,以尝试每个主题。

在创建主题结构并更改基础主题之后,让我们来构建它。为了构建它,我们将再次使用终端和 Sencha Cmd。将目录更改为 packages/masteringextjs-theme 并输入以下命令:

sencha package build

结果将类似于以下截图:

创建新主题

此命令的结果将在 packages/masteringextjs-theme 文件夹内创建 build 目录,如下所示:

创建新主题

在这个 build 文件夹内,我们可以找到 resources 文件夹,在资源文件夹内我们可以找到一个名为 masteringextjs-theme-all.css 的文件,它包含我们在主题上样式化的所有组件的样式(目前还没有,但我们会达到那里)。尽管我们创建了一个完整的主题(样式化所有组件),但我们不能 100%确定我们将在我们的应用程序中使用所有这些组件。Sencha Cmd 有能力过滤并创建一个只包含我们将在项目中使用的组件的 CSS 文件。因此,我们不需要手动将 masteringextjs-theme-all.css 包含在我们的应用程序中。

注意

build/masteringextjs-theme 内部也将创建一个 masteringextjs-theme.pkg 文件。我们可以使用此文件将主题包分发给其他开发者。有关更多信息,请参阅 docs.sencha.com/cmd/5.x/cmd_packages/cmd_packages.html

因此,让我们设置我们的项目,使其可以使用我们的主题。在 app.json 中,找到主题条目并将其更改为:

"theme": "masteringextjs-theme",

当在终端中执行sencha app watch时,我们将能够看到Packt-all.css将被覆盖。当我们刷新应用程序时,将不会有任何变化,因为我们还没有开始自定义我们的主题。

在进行下一步操作时,非常重要的一点是在我们做出更改的同时保持sencha app watch运行。这样我们只需刷新浏览器就能看到所做的修改。

更改基本颜色

让我们现在开始自定义主题!让我们回到packages/masteringextjs-theme文件夹。这是我们的主题自定义方式:

  1. sass/var文件夹内,创建一个名为Component.scss的新文件。让我们向其中添加以下内容:

    $base-color: #317040 !default;
    
  2. 在前面的代码中,我们声明了一个名为$base-color的 Sass 变量,其值为绿色。这将把主题的基本颜色从蓝色改为绿色。让我们在我们的主题上应用这些更改并查看结果。

  3. 打开浏览器,我们会看到如下截图:更改基本颜色

只用一行代码,我们就得到了一个全新的主题!我们可以继续添加更多样式到我们的自定义主题,并自定义每一个组件。

自定义组件

让我们在我们的主题中进行一些更改。在Component.scss内部,添加以下代码:

$neutral-color: #8DF98B !default;

输出将是以下内容:

自定义组件

注意按钮背景、网格列标题以及折叠菜单内的面板标题是如何发生变化的。

让我们继续创建一些其他文件,以便我们可以添加更多自定义样式,如下所示:

  1. 创建以下文件和文件夹:自定义组件

  2. Accordion.scss内部,我们将添加以下代码:

    $accordion-header-color: #336600 !default;
    

    这将改变折叠菜单内面板标题的颜色。

  3. Panel.scss内部,我们将添加以下代码:

    $panel-light-header-color: #336600 !default;
    

    此颜色将用于创建面板组件的不同绿色色调。

  4. Bar.scss内部,我们将添加以下代码:

    $tabbar-background-gradient: 'bevel' !default;
    

    这将改变标签面板栏的渐变效果。可能的值可以在goo.gl/fapTBA找到。

  5. Tab.scss内部,我们将添加以下代码:

    $tab-base-color-active: #E6F5EB !default;
    $tab-base-color-focus-over: #339933 !default;
    $tab-base-color-focus-active: #B2E0C2 !default;
    

    这将改变标签的颜色,使其呈现不同的绿色色调。

    到目前为止,这是我们得到的输出:

    自定义组件

    尝试将其与之前的输出截图进行比较。

  6. Button.scss内部,我们将添加以下代码:

    $packt-background-color: #669999 !default;
    $packt-mate-gradient: 'matte' !default;
    $packt-light-color: #339933 !default;
    $packt-dark-color: #006600 !default;
    
    $button-default-base-color: $packt-background-color;
    $button-default-base-color-over: $packt-dark-color;
    $button-default-base-color-pressed: $packt-dark-color;
    $button-default-base-color-disabled: mix(#000, $packt-dark-color, 8%);
    $button-default-border-color: $packt-dark-color !default;
    
    $button-small-font-weight: bold !default;
    $button-medium-font-weight: bold !default;
    $button-large-font-weight: bold !default;
    
    $button-default-color: #fff !default;
    
    $button-default-glyph-color: $button-default-color;
    
    $button-small-border-radius: 1px !default;
    $button-medium-border-radius: 1px !default;
    $button-large-border-radius: 1px !default;
    
    $button-default-background-gradient: $packt-mate-gradient;
    $button-default-background-gradient-disabled: $packt-mate-gradient;
    $button-default-background-gradient-over: $packt-mate-gradient;
    $button-default-background-gradient-pressed: $packt-mate-gradient;
    
    $button-toolbar-border-color: $packt-background-color;
    
    $button-toolbar-background-color: $packt-light-color;
    $button-toolbar-background-color-over: $packt-dark-color;
    $button-toolbar-background-color-pressed: $packt-dark-color;
    $button-toolbar-background-color-disabled: mix(#000, $packt-dark-color, 8%);
    
    $button-toolbar-color: #fff !default;
    
    $button-toolbar-background-gradient: $packt-mate-gradient;
    $button-toolbar-background-gradient-disabled: $packt-mate-gradient;
    $button-toolbar-background-gradient-focus: $packt-mate-gradient;
    $button-toolbar-background-gradient-over: $packt-mate-gradient;
    $button-toolbar-background-gradient-pressed: $packt-mate-gradient;
    
    $button-default-glyph-color: #fff !default;
    

    这将是我们的新自定义按钮的输出:

    自定义组件

按钮现在看起来非常不同(包括普通按钮和放置在工具栏内的按钮)。

创建新主题时的一些有用提示

在 Ext JS 中,没有关于如何创建完全自定义主题的食谱或详细教程。通常,Ext JS 使用的 Sass 变量的名称是自解释的。例如,$button-default-glyph-color是用于渲染按钮图标的颜色。

以下是一些在创建 Ext JS 主题过程中可能有用的提示:

  • 尝试学习 Sass 和 Compass。Sass 和 Compass 有混合和有用的功能来处理颜色,以及其他功能(sass-lang.com/documentation/file.SASS_REFERENCE.html)。

  • 查看作为基础主题使用的主题的变量和当前值。你可以在 ext/packages/ext-theme-neptune/sass/var 中找到其源代码。

  • 实验!学习新事物的最佳方式是实践。一个不错的方法是复制之前列出的目录中的原始文件(例如,Button.scss),开始更改变量值,看看会发生什么!

  • 在实验过程中,尝试使用不同的颜色(如红色、黄色、黑色、蓝色或任何你喜欢的颜色,以便对比),这样你就可以确切地看到主题中发生了什么变化!

  • 咨询文档。Ext JS 中的每个类都有一个使用 Sass 变量的部分,包括描述和可能的值。确保充分利用它:创建新主题时的有用提示

创建自定义 UI

Ext JS 还支持 UI,这是可以应用到特定组件上的特殊主题。例如,假设我们想要为 打印导出为 PDF导出为 Excel 按钮应用不同的主题。我们可以创建一个 UI。

  1. 第一步是检查文档,了解可用的 UI,即称为 CSS Mixins 的内容:创建自定义 UI

  2. 然后,检查创建此混合所需的所有变量。我们可以在之前创建的 masteringextjs-theme/sass/var/button/Button.scss 文件中声明自定义变量,如下所示:

    $button-packt-custom-color: #336600 !default;
    $packt-custom-base-color: #C2E0D1 !default;
    
    $button-packt-custom-small-border-radius: $button-small-border-radius;
    $button-packt-custom-small-border-width: 1px;
    
    $button-packt-custom-base-color: $packt-custom-base-color;
    $button-packt-custom-base-color-over: $packt-dark-color;
    $button-packt-custom-base-color-focus: $packt-dark-color;
    $button-packt-custom-base-color-pressed: $packt-dark-color;
    $button-packt-custom-base-color-disabled: lighten($button-packt-custom-base-color, 40%);
    
    $button-packt-custom-small-border-radius: 0px;
    $button-packt-custom-small-border-width: 1px;
    
    $button-packt-custom-border-color: darken($button-packt-custom-base-color, 20%);
    $button-packt-custom-border-color-over:$button-packt-custom-border-color;
    $button-packt-custom-border-color-focus:$button-packt-custom-border-color;
    $button-packt-custom-border-color-pressed:$button-packt-custom-border-color-over;
    $button-packt-custom-border-color-disabled:lighten($button-packt-custom-border-color, 40%);
    
    $button-packt-custom-small-padding: 2px;
    $button-packt-custom-small-text-padding: 4px;
    
    $button-packt-custom-background-color:$button-packt-custom-base-color;
    $button-packt-custom-background-color-over:$button-packt-custom-border-color;
    $button-packt-custom-background-color-focus:$button-packt-custom-background-color;
    $button-packt-custom-background-color-pressed:$button-packt-custom-base-color-pressed;
    $button-packt-custom-background-color-disabled:$button-packt-custom-base-color-disabled;
    
    $button-packt-custom-background-gradient: 'mate';
    $button-packt-custom-background-gradient-over: 'mate';
    $button-packt-custom-background-gradient-focus: 'mate';
    $button-packt-custom-background-gradient-pressed: 'mate-reverse';
    $button-packt-custom-background-gradient-disabled: 'mate';
    
    $packt-custom-font-size: 12px;
    $packt-custom-font-family: helvetica , arial , verdana , sans-serif;
    
    $button-packt-custom-color-over: #fff !default;
    $button-packt-custom-color-focus: $button-packt-custom-color-over;
    $button-packt-custom-color-pressed: $button-packt-custom-color-over;
    $button-packt-custom-color-disabled: $button-packt-custom-color-over;
    
    $button-packt-custom-small-font-size: $packt-custom-font-size;
    $button-packt-custom-small-font-size-over: $packt-custom-font-size;
    $button-packt-custom-small-font-size-focus: $packt-custom-font-size;
    $button-packt-custom-small-font-size-pressed: $packt-custom-font-size;
    $button-packt-custom-small-font-size-disabled: $packt-custom-font-size;
    
    $button-packt-custom-small-font-weight: $button-small-font-weight;
    $button-packt-custom-small-font-weight-over: $button-small-font-weight;
    $button-packt-custom-small-font-weight-focus: $button-small-font-weight;
    $button-packt-custom-small-font-weight-pressed: $button-small-font-weight;
    $button-packt-custom-small-font-weight-disabled: $button-small-font-weight;
    
    $button-packt-custom-small-font-family: $packt-custom-font-family;
    $button-packt-custom-small-font-family-over: $packt-custom-font-family;
    $button-packt-custom-small-font-family-focus: $packt-custom-font-family;
    $button-packt-custom-small-font-family-pressed: $packt-custom-font-family;
    $button-packt-custom-small-font-family-disabled: $packt-custom-font-family;
    
    $button-packt-custom-small-icon-size: 16px;
    $button-packt-custom-glyph-color: $button-packt-custom-color;
    $button-packt-custom-glyph-opacity: .5;
    $button-packt-custom-small-arrow-width: 12px;
    $button-packt-custom-small-arrow-height: 12px;
    $button-packt-custom-small-split-width: 14px;
    $button-packt-custom-small-split-height: 14px;
    
  3. 然后,我们将创建一个名为 masteringextjs-theme/sass/src/button/Button.scss 的新文件,包含我们的自定义 UI:

    @include extjs-button-ui(
      $ui: 'custom-btn-small',
    
      $border-radius: $button-packt-custom-small-border-radius,
      $border-width: $button-packt-custom-small-border-width,
    
      $border-color: $button-packt-custom-border-color,
      $border-color-over: $button-packt-custom-border-color-over,
      $border-color-focus: $button-packt-custom-border-color-focus,
      $border-color-pressed: $button-packt-custom-border-color-pressed,
      $border-color-disabled: $button-packt-custom-border-color-disabled,
    
      $padding: $button-packt-custom-small-padding,
      $text-padding: $button-packt-custom-small-text-padding,
    
      $background-color: $button-packt-custom-background-color,
      $background-color-over: $button-packt-custom-background-color-over,
      $background-color-focus: $button-packt-custom-background-color-focus,
      $background-color-pressed: $button-packt-custom-background-color-pressed,
      $background-color-disabled: $button-packt-custom-background-color-disabled,
    
      $background-gradient: $button-packt-custom-background-gradient,
      $background-gradient-over: $button-packt-custom-background-gradient-over,
      $background-gradient-focus: $button-packt-custom-background-gradient-focus,
      $background-gradient-pressed: $button-packt-custom-background-gradient-pressed,
      $background-gradient-disabled: $button-packt-custom-background-gradient-disabled,
    
      $color: $button-packt-custom-color,
      $color-over: $button-packt-custom-color-over,
      $color-focus: $button-packt-custom-color-focus,
      $color-pressed: $button-packt-custom-color-pressed,
      $color-disabled: $button-packt-custom-color-disabled,
    
      $font-size: $button-packt-custom-small-font-size,
      $font-size-over: $button-packt-custom-small-font-size-over,
      $font-size-focus: $button-packt-custom-small-font-size-focus,
      $font-size-pressed: $button-packt-custom-small-font-size-pressed,
      $font-size-disabled: $button-packt-custom-small-font-size-disabled,
    
      $font-weight: $button-packt-custom-small-font-weight,
      $font-weight-over: $button-packt-custom-small-font-weight-over,
      $font-weight-focus: $button-packt-custom-small-font-weight-focus,
      $font-weight-pressed: $button-packt-custom-small-font-weight-pressed,
      $font-weight-disabled: $button-packt-custom-small-font-weight-disabled,
    
      $font-family: $button-packt-custom-small-font-family,
      $font-family-over: $button-packt-custom-small-font-family-over,
      $font-family-focus: $button-packt-custom-small-font-family-focus,
      $font-family-pressed: $button-packt-custom-small-font-family-pressed,
      $font-family-disabled: $button-packt-custom-small-font-family-disabled,
    
      $icon-size: $button-packt-custom-small-icon-size,
      $glyph-color: $button-packt-custom-glyph-color,
      $arrow-width: $button-packt-custom-small-arrow-width,
      $arrow-height: $button-packt-custom-small-arrow-height,
      $split-width: $button-packt-custom-small-split-width,
      $split-height: $button-packt-custom-small-split-height,
      $opacity-disabled: $button-opacity-disabled,
      $inner-opacity-disabled: $button-inner-opacity-disabled
    );
    

注意,我们正在将自定义变量分配给创建此 UI 所需的混合变量。由于 Ext JS 中的按钮有三种大小,目前我们只为小按钮声明自定义 UI,但也可以为其他大小做同样的事情。

应用 UI

在我们的代码中,我们将创建一个名为 app/view/base/CustomButton.js 的新文件,内容如下:

Ext.define('Packt.view.base.CustomButton', {
    extend: 'Ext.button.Button',
    xtype: 'custom-btn',

    ui: 'custom-btn'
});

然后,我们将替换我们想要应用此 UI 的按钮的 xtype 类(创建一个超级类比将 ui 配置应用到每个组件上更容易,但是否这样做取决于你)。我们将替换 PrintExport to PDFExport to Excel 按钮的 xtype 配置,如下面的代码所示(Films.js 文件):

xtype: 'custom-btn',
text: 'Print',

如果我们再次尝试执行我们的应用程序,这将是我们得到的输出:

应用 UI

注意,打印导出按钮与添加按钮看起来不同。

我们可以创建我们需要的任意多个 UI,并且对于任何支持它的组件。也有可能创建不属于主题的 UI,这意味着我们可以在masteringextjs/sass文件夹内创建它,遵循我们在本主题中遵循的相同结构。

现在,你只需要释放出你内心存在的那个设计师!

为生产打包应用程序

我们的主题已经创建,所以现在唯一剩下的事情就是进行生产构建并将代码部署到生产 Web 服务器上。同样,我们将再次使用 Sencha Cmd 来完成这项工作。

  1. 要进行生产构建,我们需要打开一个终端。我们还需要将目录切换到应用程序的根目录,并输入以下命令:

    sencha app build
    

    下面是终端上的命令外观:

    为生产打包应用程序

  2. 一旦命令执行完成,它将创建一个名为build/production/NameofTheApp的新目录。由于我们的应用程序命名空间是Packt,它创建了build/production/Packt目录,如下所示:为生产打包应用程序

    这个命令的作用是获取我们开发的全部代码(位于app文件夹内)以及运行应用程序所需的 Ext JS 代码,并将它们放入all-classes.js文件中。然后,使用YUI Compressor,Sencha Cmd 将最小化代码并混淆 JavaScript 代码;这样,我们将得到一个非常小的 JavaScript 文件,用户需要加载它。此外,Sencha Cmd 将评估我们应用程序使用的所有组件,过滤掉不需要的 CSS,并将其放入resources/Packt-all.css文件中。所有我们的自定义图片(图标图片)也将从开发环境复制到production文件夹(同样位于resources文件夹内)。

  3. 下一步是确保生产构建按预期工作。要访问开发环境,我们使用http://localhost/masteringextjs。要测试生产构建,我们需要访问http://localhost/masteringextjs/build/production/Packt。当我们测试时,我们会发现它并没有按我们预期的那样工作。我们会遇到一些错误。

  4. 接下来,我们需要将php文件夹也复制到production文件夹中,如下截图所示:为生产打包应用程序

  5. 我们也应该复制ext/packages/ext-locale/build以及我们将要使用的区域设置文件。

    现在,我们可以再次测试应用程序。它应该按预期工作。

编译 ext-locale

这里有一个关于ext-locale包的快速说明:如果你只使用一个区域设置,你可以在app.jsonrequires中添加ext-locale包,并添加一个新的条目"locale" : "es",其中包含你想要使用的区域设置的代码。Ext JS 将编译所需的文件。

如果您使用多个区域设置,有两种选择:像我们这样做(手动复制文件)或者为每个区域设置制作生产构建。您可以通过探索app.jsonEXT JS Kitchen Sink示例的源代码(dev.sencha.com/ext/5.0.1/examples/kitchensink/)来查看如何操作。

生产环境中要部署的内容

总是记住,我们有一个app文件夹,以及所有作为开发环境开发的代码。而在production文件夹中,我们拥有所有应该在生产环境中部署的代码。

所以,假设我们现在想部署这个应用程序。只需将masteringextjs/build/production/Packt中的所有内容传输到您的 Web 服务器上的目标文件夹,如下所示:

生产环境中要部署的内容

欢迎使用生产代码!

好处

生产构建有哪些好处?我们能否直接部署开发代码?我们可以在生产环境中直接部署开发代码,但并不推荐。使用生产构建,我们在加载文件时提升了性能,但文件被最小化,这也使得代码更难以阅读。

例如,让我们进行以下测试:在浏览器中打开应用程序,登录,并从静态数据模块打开演员屏幕。

使用开发代码,我们从 Chrome 开发者工具(或 Firebug)将得到以下结果:

好处

应用程序发出了657请求,导致向用户传输了7.7 MB的数据,完成整个过程耗时17.50 秒。这已经很多了,而且提到向用户传输7.7 MB是不可接受的!

现在我们来看看使用生产构建的结果:

好处

应用程序发出了47次请求,并传输了 1.9 MB的数据。最重要的变化是传输的数据大小:从7.7 MB减少到1.9 MB!这是一个巨大的改进,尽管1.9 MB仍然是一个很大的数据传输量。文件将被缓存,这个数字还会进一步减少。

另一点需要注意是正在加载的文件。在开发环境中,我们可以看到浏览器正在加载每个 Ext JS 类:

好处

最后,仅仅为了渲染登录屏幕,就需要加载超过 400 个 JavaScript 文件。

如果我们尝试生产构建,我们会得到以下结果:

好处

应用程序只加载了一个 JavaScript 文件,即应用程序的源代码和所需的 Ext JS SDK 代码(app.js)。

因此,出于性能考虑,始终部署生产构建。仅将开发代码用于开发目的。

注意

如果应用程序开始增长,传输到浏览器中的数据量将超过 2 MB,这并不好。您可以创建独立的小应用程序,并将它们像在门户应用程序中一样组合起来。这样,用户将能够只下载即将使用的应用程序部分的应用程序文件,而无需一次性下载整个应用程序的源代码。此链接包含有关此主题的良好讨论:goo.gl/az8uVT

摘要

在本章中,我们学习了如何创建新的主题,我们还学习了如何创建自定义组件 UI。您了解到为什么制作生产构建很重要,以及如何进行,包括开发环境与生产环境之间文件的区别。

我希望您喜欢这本书!现在,让创造力流淌,创造出真正出色的 Ext JS 应用程序!

posted @ 2025-10-26 08:55  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报