使用 AngularJS、WebApi、SignalR 和 HTML5 构建看板应用程序
使用 AngularJS、WebApi、SignalR 和 HTML5 构建看板 Web 应用程序
引言
在本文中,我将介绍使用 AngularJS、WebApi、SignalR 和 HTML5 创建看板 Web 应用程序的步骤。本文的目的是向您展示这些技术如何和谐地协同工作,以构建一个易于扩展和管理的应用程序。
一览众山小
图 1 显示了各种技术如何堆叠在一起以生成整个解决方案
图 1:解决方案中使用的整体技术
图 2 更详细地展示了这些组件如何相互交互。我将在文章后面详细介绍每个组件。您可以在 这里 查看完整尺寸的图像。
图 2:解决方案的整体架构。在此 处 查看完整尺寸的图像。
服务器
解决方案的服务器组件是 ASP.NET MVC、WebApi 和 SignalR 服务器。MVC 应用程序充当应用程序的容器,因此除了一个用于提供索引页面的控制器之外,没有太多实现。将 MVC 应用程序作为容器的想法是为了利用脚本打包功能以及其他好处,例如布局页面。因此,服务器组件的解释将侧重于 WebApi 和 SignalR 服务器。
WebAPI
BoardWebApiController
包含了解决方案的 HTTP 服务部分。BoardWebApi HTTP 服务包含以下服务:Get、CanMove 和 MoveTask。以下是对每项服务的解释。
默认的 Get 方法将返回看板上的列及其任务。列表 1 显示了此方法的实现。该方法使用 BoardRepository 来检索所有列,然后将数据序列化为 JSON 格式,然后以 HttpResponseMessage 的形式返回给客户端。我将在本文后面介绍 BoardRepository
。
[HttpGet]
public HttpResponseMessage Get()
{
var repo = new BoardRepository();
var response = Request.CreateResponse();
response.Content = new StringContent(JsonConvert.SerializeObject(repo.GetColumns()));
response.StatusCode = HttpStatusCode.OK;
return response;
}
列表 1:Get HTTP 服务
第二个服务是 CanMove。此服务在任务可以从一个列移动到另一个列之前调用。在看板中,任务只能从左到右(拉取)移动。此服务获取源列和目标列 ID,然后返回一个布尔值以指示任务是否可以在这两个列之间移动。列表 2 显示了 CanMove 服务的实现。
[HttpGet]
public HttpResponseMessage CanMove(int sourceColId, int targetColId)
{
var response = Request.CreateResponse();
response.StatusCode = HttpStatusCode.OK;
response.Content = new StringContent(JsonConvert.SerializeObject(new { canMove = false }));
if (sourceColId == (targetColId - 1))
{
response.Content = new StringContent(JsonConvert.SerializeObject(new { canMove = true }));
}
return response;
}
列表 2:CanMove HTTP 服务
最后一个服务是 MoveTask。顾名思义,此服务将一个任务从其当前列移动到目标列。列表 3 显示了 MoveTask 的实现。请注意,我是如何使用 JObject 来包装此服务所需的 2 个参数的。
[HttpPost]
public HttpResponseMessage MoveTask(JObject moveTaskParams)
{
dynamic json = moveTaskParams;
var repo = new BoardRepository();
repo.MoveTask((int)json.taskId, (int)json.targetColId);
var response = Request.CreateResponse();
response.StatusCode = HttpStatusCode.OK;
return response;
}
列表 3:MoveTask HTTP 服务
正如我们接下来将看到的,存储库中的 MoveTask 方法将执行繁重的工作。
BoardRepository
BoardRepository 在内存中的列集合上工作。一列包含任务列表。任务包含对其当前列的引用。列表 4 显示了 Column 和 Task 的定义。
public partial class Column
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public virtual List<Task> Tasks { get; set; }
}
public class Task
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public int ColumnId { get; set; }
}
列表 4:Column 和 Task 类
BoardRepository
包含以下方法:GetColumn、GetTask、GetColumns、MoveTask 和 UpdateColumns。
GetColumns 将在缓存可用时返回列集合,否则将创建一个新的。GetColumn 返回一个对应于传入列 ID 的列,GetTask 返回一个对应于传入任务 ID 的任务。列表 5 显示了 GetColumns、GetColumn 和 GetTask 的实现。
public List<Column> GetColumns() { if (HttpContext.Current.Cache["columns"] == null) { var columns = new List<Column>(); var tasks = new List<Task>(); for (int i = 1; i < 6; i++) { // Generating tasks is removed from this code snippet for brevity HttpContext.Current.Cache["columns"] = columns; } } return (List<Column>)HttpContext.Current.Cache["columns"]; } public Column GetColumn(int colId) { return (from c in this.GetColumns() where c.Id == colId select c).FirstOrDefault(); } public Task GetTask(int taskId) { var columns = this.GetColumns(); foreach (var c in columns) { foreach (var task in c.Tasks) { if (task.Id == taskId) return task; } } return null; }
列表 5:BoardRepository - 1
BoardRepository
中剩余的两个方法是 MoveTask 和 UpdateColumns。MoveTask 将获取一个任务 ID 和任务将移动到的目标列 ID。然后它移动任务并调用 UpdateColumns 来更新缓存中的列集合。
UpdateColumns 将接收列集合并相应地更新缓存。列表 6 显示了 MoveTask 和 UpdateColumns 的实现。
public void MoveTask(int taskId, int targetColId)
{
var columns = this.GetColumns();
var targetColumn = this.GetColumn(targetColId);
// Add task to the target column
var task = this.GetTask(taskId);
var sourceColId = task.ColumnId;
task.ColumnId = targetColId;
targetColumn.Tasks.Add(task);
// Remove task from source column
var sourceCol = this.GetColumn(sourceColId);
sourceCol.Tasks.RemoveAll(t => t.Id == taskId);
// Update column collection
columns.RemoveAll(c => c.Id == sourceColId || c.Id == targetColId);
columns.Add(targetColumn);
columns.Add(sourceCol);
this.UpdateColumns(columns.OrderBy(c => c.Id).ToList());
}
private void UpdateColumns(List<Column> columns)
{
HttpContext.Current.Cache["columns"] = columns;
}
列表 6:BoardRepository - 2
客户端
客户端包含 2 部分:UI 和 AngularJS。boardService 包含 SignalR 代理。
看板 UI
此应用程序中的看板由 4 列组成,它们是:待办、进行中、测试和完成。看板会根据团队的不同而有很大差异,但大多数时候您会在任何软件开发看板中或多或少地找到这些列。图 3 显示了我们的 KanbanBoard 应用程序的 UI。
图 3:看板 UI
看板标记
看板后面的标记包含在 index.cshtml 中。标记由 AngularJS 控制器 (boardCtrl
) 控制,我将在后面详细介绍。列表 7 显示了看板后面的标记。
<div style="width:80%; margin: 0 auto 0 auto;">
<div class="row" ng-controller="boardCtrl">
<!-- Loading indicator: Listing 8 -->
<!-- Board Columns: Listing 9 -->
</div>
</div>
列表 7:看板 UI 标记
boardCtrl
div 包含两个 div 元素。第一个是加载指示器,第二个是列集合 div 容器。列表 8 显示了加载 div 的定义。
<div ng-include="'/AppScript/busyModal.html'" ng-show="isLoading"></div>
列表 8:加载指示器
此 div 使用两个 AngularJS 指令:ng-include 和 ng-show。ng-include 指向将为加载指示器显示的 html 模板。注意我是如何以字面字符串的形式传递模板名称的。这告诉 AngularJS 将其视为字符串而不是表达式。这很有用,因为在某些情况下,您可能希望根据给定上下文包含不同的模板,因此,而不是字面值,您可以传递一个作用域变量,该变量可以具有不同的值。
另一方面,ng-show 将根据在 boardCtrl
中设置的模型变量 isLoading 来隐藏或显示加载指示器。
第二部分是显示列集合。列表 9 显示了显示看板列的标记。
<div class="col-lg-3 panel panel-primary colStyle" id="{{col.Id}}"
kanban-board-drop="over" ng-repeat="col in columns">
<div class="panel-heading" style="margin-bottom: 10px;">
<h3 class="panel-title">{{col.Name}}</h3>
</div>
<div class="thumbnail" draggable="true" kanban-board-dragg="task"
ng-repeat="task in col.Tasks" style="margin-bottom: 10px;">
<div class="caption">
<h5><strong>{{task.Name}}</strong></h5>
<p>{{task.Description}}</p>
<p><a href="#" class="btn btn-primary btn-sm" role="button">Edit</a></p>
</div>
</div>
</div>
列表 9:显示列的标记
此标记使用 ng-repeat 指令循环遍历列集合。内部的 ng-repeat 循环遍历每个列中的任务以显示任务。双花括号 {{}} 是 AngularJS 绑定表达式。我使用它们来显示列和任务的各种信息。注意我是如何将列 div 容器的 ID 分配给列 ID 的,这样在我实现本文后面的拖放功能时,我就会有一个引用。
kanban-board-drop
和 kanban-board-dragg
都是自定义指令,用于处理列之间的拖放功能。我将稍后详细介绍这些指令,但目前,kanban-board-drop 属性接受将用于目标列悬停效果的 CSS 类名称。kanban-board-dragg 指令接受被拖动的数据项,即任务。
AngularJS
此应用程序中的 AngularJS 开发可分为以下几个部分:模块、控制器、服务和自定义指令。如果您熟悉 AngularJS,您会知道这些实际上是任何 AngularJS 应用程序中的主要组件。
AngularJS 模块
AngularJS 模块充当所有可与其一起发布的组件的容器。将其视为 .NET 世界中的动态链接库 (DLL)。列表 10 显示了我们的 kanbanBoardApp
模块的实现。
// application global namespace
var sulhome = sulhome || {};
sulhome.kanbanBoardApp = angular.module('kanbanBoardApp',[]);
列表 10:kanbanBoardApp 定义
我首先定义一个名为 sulhome 的命名空间。这是一个好习惯,这样您的 JavaScript 代码就不会与其他可能在页面其他地方定义的全局变量(来自其他库)混合。kanbanBoardApp
模块被创建并分配给命名空间上的一个变量。
board 控制器 (boardCtrl)
在 AngularJS 中,控制器的作用是管理模型,即视图之间双向绑定的数据。boardCtrl
通过维护看板模型(在本例中是 columns
集合)和控制加载指示器的 isLoading
变量来完成此操作。列表 11 显示了 boardCtrl
的实现。
sulhome.kanbanBoardApp.controller('boardCtrl', function ($scope, boardService) {
// Model
$scope.columns = [];
$scope.isLoading = false;
function init() {
$scope.isLoading = true;
boardService.initialize().then(function (data) {
$scope.isLoading = false;
$scope.refreshBoard();
}, onError);
};
$scope.refreshBoard = function refreshBoard() {
$scope.isLoading = true;
boardService.getColumns()
.then(function (data) {
$scope.isLoading = false;
$scope.columns = data;
}, onError);
};
$scope.onDrop = // Listing 16
// Listen to the 'refreshBoard' event and refresh the board as a result
$scope.$parent.$on("refreshBoard", function (e) {
$scope.refreshBoard();
toastr.success("Board updated successfully", "Success");
});
var onError = function (errorMessage) {
$scope.isLoading = false;
toastr.error(errorMessage, "Error");
};
init();
});
列表 11:kanbanBoardApp 定义
boardService
被注入到控制器中。正如我们稍后将看到的,此服务负责与 HTTP 服务通信。SignalR 代理也驻留在此服务中(boardService)。
控制器首先定义模型变量,即 columns
和 isLoading
。init 函数在控制器加载后自动调用。此函数调用服务来初始化 SignalR 代理,然后调用 refreshBoard,后者又调用服务来检索列。onError 函数用于将与 HTTP 服务通信时可能发生的错误记录到屏幕上。我使用 John Papa 的 toastr 来显示错误。
控制器还侦听一个名为 refreshBoard
的事件。此事件将从 SignalR 代理引发,它将允许客户端刷新其看板以获取最新的列。此事件一旦从一个列移动到另一个列,就会被引发,因此它确保所有客户端都彼此同步,即所有客户端都具有最新的看板。
此控制器缺少一个方法,即 onDrop 方法。我将在讨论本文后面的拖放功能时讨论此方法。
board 服务 (boardService)
board 服务负责与 HTTP 服务和 SignalR 服务器通信。前 3 个方法 getColumns、canMoveTask 和 moveTask 调用前面解释的相应 HTTP 服务,并将结果返回给调用者,即控制器 (boardCtrl
)。列表 12 显示了这些方法的实现。
sulhome.kanbanBoardApp.service('boardService', function ($http, $q, $rootScope) {
var getColumns = function () {
return $http.get("/api/BoardWebApi").then(function (response) {
return response.data;
}, function (error) {
return $q.reject(error.data.Message);
});
};
var canMoveTask = function (sourceColIdVal, targetColIdVal) {
return $http.get("/api/BoardWebApi/CanMove",
{ params: { sourceColId: sourceColIdVal, targetColId: targetColIdVal } })
.then(function (response) {
return response.data.canMove;
}, function (error) {
return $q.reject(error.data.Message);
});
};
var moveTask = function (taskIdVal, targetColIdVal) {
return $http.post("/api/BoardWebApi/MoveTask",
{ taskId: taskIdVal, targetColId: targetColIdVal })
.then(function (response) {
return response.status == 200;
}, function (error) {
return $q.reject(error.data.Message);
});
};
var initialize = // Listing 12: boardService -2
var sendRequest = // Listing 12: boardService -2
return {
initialize: initialize,
sendRequest: sendRequest,
getColumns: getColumns,
canMoveTask: canMoveTask,
moveTask: moveTask
};
});
列表 12:boardService -1
getColumns、canMoveTask 和 moveTask 方法使用 AngularJS 的 $http
服务与看板 HTTP 服务通信。这些函数处理响应以返回数据,而不是发送原始响应。如果发生错误,将使用 Promise 服务 ($q
) 拒绝请求,并在控制器 (boardCtrl
) 上调用 onError 方法。这些函数还使用 $http
服务的简写版本,即 $http.get 和 $http.post。如您所见,这些方法匹配 get 和 post 的 HTTP 动词。使用此简写版本可以避免指定动词,即使用 $http.get 时动词为 GET,使用 $http.post 时动词为 POST。
列表 13 显示了 boardService
的其余实现。其余实现处理 SignalR 代理和事件。
sulhome.kanbanBoardApp.service('boardService', function ($http, $q, $rootScope) {
var proxy = null;
var initialize = function () {
connection = jQuery.hubConnection();
this.proxy = connection.createHubProxy('KanbanBoard');
// Listen to the 'BoardUpdated' event that will be pushed from SignalR server
this.proxy.on('BoardUpdated', function () {
$rootScope.$emit("refreshBoard");
});
// Connecting to SignalR server
return connection.start()
.then(function (connectionObj) {
return connectionObj;
}, function (error) {
return error.message;
});
};
// Call 'NotifyBoardUpdated' on SignalR server
var sendRequest = function () {
this.proxy.invoke('NotifyBoardUpdated');
};
});
列表 13:boardService -2
私有变量 proxy
包含在 initialize 函数中创建的 SignalR 代理的引用。initialize 函数连接到 SignalR 服务器并创建一个事件侦听器,该侦听器侦听从 SignalR 服务器推送的事件。
当用户将任务移动到另一个列时,SignalR 服务器会将 BoardUpdated
事件推送到所有客户端。此事件侦听器然后利用 AngularJS 的 $emit
在 AngularJS 应用程序中发布事件。如果您记得 boardCtrl
包含一个侦听 refreshBoard
事件并相应调用 refreshBoard 方法的事件侦听器。这就是看板在所有客户端上保持最新的方式。
sendRequest 方法调用 SignalR 服务器方法以指示任务已移动,以便 SignalR 服务器可以向所有客户端发送推送事件,以便它们可以更新其看板。正如您稍后将看到的,此方法(sendRequest)将从 onDrop 方法调用,该方法在任务卡成功拖放到另一个列后调用。
拖放指令
拖放功能是针对 HTML5 拖放 API 编程的。因此,只要浏览器支持 HTML5 拖放 API,它就可以工作。
AngularJS 中的 DOM 操作应在指令中进行。例如,根据条件隐藏元素(如 div)是通过使用 ng-show 来完成的,这是一个属性指令,即它被用作 DOM 元素的属性。从 DOM 操作的角度来看,拖放功能与隐藏元素没有区别。因此,它已在自己的指令中处理。
列表 14 显示了 kanbanBoardDragg
指令的实现。此指令将应用于您拖动的项,因此在我们的应用程序中,这将是任务卡。
sulhome.kanbanBoardApp.directive('kanbanBoardDragg', function () {
return {
link: function ($scope, element, attrs) {
var dragData = "";
$scope.$watch(attrs.kanbanBoardDragg, function (newValue) {
dragData = newValue;
});
element.bind('dragstart', function (event) {
event.originalEvent.dataTransfer.setData("Text", JSON.stringify(dragData));
});
}
};
});
列表 14:kanbanBoardDragg 指令
指令的默认类型是属性,因此这是一个属性指令。link 属性指向在加载指令时将调用的函数。此函数接收 3 个参数。
$scope: 这是我们示例中控制器的作用域。
element: 是在其上应用此指令的元素,在我们的例子中是任务卡 div。
attrs: 是元素上所有属性的数组。
注意 $scope.
上 $watch 函数的用法。AngularJS 将 kanbanBoardDragg
属性的值视为表达式,并在每次更改时重新评估它。在我们的例子中,这个值是 task
对象。dragstart
事件处理程序会将 dragData 存储在事件的 dataTransfer
属性中。这将允许我们在项目被放下时检索此数据,即 ondrop
事件,正如您将在 kanbanBoardDrop
指令中看到的那样。
列表 15 显示了 kanbanBoardDrop
指令的实现。dragOverClass
包含将应用于任务将被放下(移动)的目标列的 CSS 类的名称。该类在 kanbanBoardDrop
属性中设置。
sulhome.kanbanBoardApp.directive('kanbanBoardDrop', function () {
return {
link: function ($scope, element, attrs) {
var dragOverClass = attrs.kanbanBoardDrop;
// Prevent the default behavior. This has to be called in order for drob to work
cancel = function (event) {
if (event.preventDefault) {
event.preventDefault();
}
if (event.stopPropigation) {
event.stopPropigation();
}
return false;
};
element.bind('dragover', function (event) {
cancel(event);
event.originalEvent.dataTransfer.dropEffect = 'move';
element.addClass(dragOverClass);
});
element.bind('drop', function (event) {
cancel(event);
element.removeClass(dragOverClass);
var droppedData = JSON.parse(event.originalEvent.dataTransfer.getData('Text'));
$scope.onDrop(droppedData, element.attr('id'));
});
element.bind('dragleave', function (event) {
element.removeClass(dragOverClass);
});
}
};
});
列表 15:kanbanBoardDrop 指令
Dragover 和 dragleave 事件处理程序通过分别将 CSS 类添加到列或从中删除来控制 CSS 类。我使用 originalEvent
而不是 event
,因为后者是 originalEvent
的 jQuery 包装器,因此它不包含我需要用于拖放事件的方法。
dropEffect = 'move' 是将出现在目标上的视觉效果,任务将被放下到该目标上。其他值是 none、link 和 copy。设置此值不会影响功能,但它是用户界面的指示器,用于显示将拖动项放到目标上的效果。例如,如果您正在使用拖放将项目从一个列表复制到另一个列表,那么 dropEffect 将是 copy。
drop 事件处理程序读取在 drag 事件中存储的数据,然后调用控制器 (boardCtrl
) 中的 onDrop 方法。onDrop 方法接受任务对象和目标列 ID。源列 ID 可以从任务对象中读取。列表 16 显示了 boardCtrl
中的 onDrop 方法的实现。
$scope.onDrop = function (data, targetColId) {
boardService.canMoveTask(data.ColumnId, targetColId)
.then(function (canMove) {
if (canMove) {
boardService.moveTask(data.Id, targetColId).then(function (taskMoved) {
$scope.isLoading = false;
boardService.sendRequest();
}, onError);
$scope.isLoading = true;
}
}, onError);
};
列表 16:OnDrop 方法
onDrop 方法检查任务是否可以移动,如果可以,则调用 moveTask 服务方法来移动任务。一旦任务被移动,isLoading
标志将被设置为 false,并调用 sendRequest 方法。如前所述,sendRequest 将调用 SignalR 服务器发送推送事件给所有客户端,以便它们可以更新其看板。
结论
在本文中,我解释了如何使用 AngularJS、SignalR、WebApi 和 HTML5 构建看板 Web 应用程序。我希望到本文结束时,您已经很好地理解了这些技术如何协同工作以实现最终结果。
历史
V 1.0 - 24.08.2014:创建