chsakell分享了一个前端使用AngularJS,后端使用ASP.NET Web API的项目。
源码: https://github.com/chsakell/spa-webapi-angularjs
文章:http://chsakell.com/2015/08/23/building-single-page-applications-using-web-api-and-angularjs-free-e-book/
这里记录下对此项目的理解。分为如下几篇:
● 对一个前端使用AngularJS后端使用ASP.NET Web API项目的理解(1)--领域、Repository、Service
● 对一个前端使用AngularJS后端使用ASP.NET Web API项目的理解(2)--依赖倒置、Bundling、视图模型验证、视图模型和领域模型映射、自定义handler
● 对一个前端使用AngularJS后端使用ASP.NET Web API项目的理解(3)--主页面布局
● 对一个前端使用AngularJS后端使用ASP.NET Web API项目的理解(4)--Movie增改查以及上传图片
显示带分页过滤条件的Movie
/关于分页,通常情况下传的参数包括:当前页、页容量 //关于过滤:传过滤字符串 [AllowAnonymous] [Route("{page:int=0}/{pageSize=3}/{filter?}")] //给路由中的变量赋上初值 public HttpResponseMessage Get(HttpRequestMessage request, int? page, int? pageSize, string filter = null) { int currentPage = page.Value; // 当前页 int currentPageSize = pageSize.Value;//页容量 return CreateHttpResponse(request, () => { HttpResponseMessage response = null; List<Movie> movies = null; int totalMovies = new int();//总数 if (!string.IsNullOrEmpty(filter)) //如果有过滤条件 { movies = _moviesRepository.GetAll() .OrderBy(m => m.ID) .Where(m => m.Title.ToLower() .Contains(filter.ToLower().Trim())) .ToList(); } else { movies = _moviesRepository.GetAll().ToList(); } totalMovies = movies.Count(); //总数量 movies = movies.Skip(currentPage * currentPageSize) .Take(currentPageSize) .ToList(); //领域模型转换成视图模型 IEnumerable<MovieViewModel> moviesVM = Mapper.Map<IEnumerable<Movie>, IEnumerable<MovieViewModel>>(movies); PaginationSet<MovieViewModel> pagedSet = new PaginationSet<MovieViewModel>() { Page = currentPage, TotalCount = totalMovies, TotalPages = (int)Math.Ceiling((decimal)totalMovies / currentPageSize), Items = moviesVM }; //再把视图模型和分页等数据放在响应中返回 response = request.CreateResponse<PaginationSet<MovieViewModel>>(HttpStatusCode.OK, pagedSet); return response; }); }
PagenationSet<T>是对分页和领域模型集合的一个封装。
namespace HomeCinema.Web.Infrastructure.Core { public class PaginationSet<T> { public int Page { get; set; } public int Count { get { return (null != this.Items) ? this.Items.Count() : 0; } } public int TotalPages { get; set; } public int TotalCount { get; set; } public IEnumerable<T> Items { get; set; } } }
界面部分,首页中使用了一个自定义的directive。
<side-bar></side-bar>
在side-bar所对应的html部分提供了获取所有Movie的链接。
<a ng-href="#/movies/">Movies<i class="fa fa-film fa-fw pull-right"></i></a>
而在app.js的路由设置中:
.when("/movies", { templateUrl: "scripts/spa/movies/movies.html", controller: "moviesCtrl" })
先来看scripts/spa/movies/movies.html页面摘要:
<!--过滤--> <input id="inputSearchMovies" type="search" ng-model="filterMovies" placeholder="Filter, search movies.."> <button class="btn btn-primary" ng-click="search();"></button> <button class="btn btn-primary" ng-click="clearSearch();"></button> <!--列表--> <a class="pull-left" ng-href="#/movies/{{movie.ID}}" title="View {{movie.Title}} details"> <img class="media-object" height="120" ng-src="../../Content/images/movies/{{movie.Image}}" alt="" /> </a> <h4 class="media-heading">{{movie.Title}}</h4> <strong>{{movie.Director}}</strong> <strong>{{movie.Writer}}</strong> <strong>{{movie.Producer}}</strong> <a class="fancybox-media" ng-href="{{movie.TrailerURI}}">Trailer<i class="fa fa-video-camera fa-fw"></i></a> <span component-rating="{{movie.Rating}}"></span> <label class="label label-info">{{movie.Genre}}</label> <available-movie is-available="{{movie.IsAvailable}}"></available-movie> <!--分页--> <custom-pager page="{{page}}" custom-path="{{customPath}}" pages-count="{{pagesCount}}" total-count="{{totalCount}}" search-func="search(page)"></custom-pager>
再来看对应的moviesCtrl控制器:
(function (app) { 'use strict'; app.controller('moviesCtrl', moviesCtrl); moviesCtrl.$inject = ['$scope', 'apiService','notificationService']; //所有变量都赋初值pageClass, loadingMovies, page, pagesCount, movies, search方法,clearSearch方法 function moviesCtrl($scope, apiService, notificationService) { $scope.pageClass = 'page-movies'; $scope.loadingMovies = true; $scope.page = 0; $scope.pagesCount = 0; $scope.Movies = []; $scope.search = search; $scope.clearSearch = clearSearch; //当前页索引作为参数传递 function search(page) { page = page || 0; $scope.loadingMovies = true; //这里的object键值将被放在路由中以便action方法接收 var config = { params: { page: page, pageSize: 6, filter: $scope.filterMovies } }; apiService.get('/api/movies/', config, moviesLoadCompleted, moviesLoadFailed); } function moviesLoadCompleted(result) { $scope.Movies = result.data.Items; $scope.page = result.data.Page; $scope.pagesCount = result.data.TotalPages; $scope.totalCount = result.data.TotalCount; $scope.loadingMovies = false; if ($scope.filterMovies && $scope.filterMovies.length) { notificationService.displayInfo(result.data.Items.length + ' movies found'); } } function moviesLoadFailed(response) { notificationService.displayError(response.data); } function clearSearch() { $scope.filterMovies = ''; search(); } $scope.search(); } })(angular.module('homeCinema'));
然后对于分页,当然需要自定义directive,如下:
<custom-pager page="{{page}}" custom-path="{{customPath}}" pages-count="{{pagesCount}}" total-count="{{totalCount}}" search-func="search(page)"></custom-pager>
对应的html部分来自spa/layout/pager.html
<div> <div ng-hide="(!pagesCount || pagesCount < 2)" style="display:inline"> <ul class="pagination pagination-sm"> <li><a ng-hide="page == 0" ng-click="search(0)"><<</a></li> <li><a ng-hide="page == 0" ng-click="search(page-1)"><</a></li> <li ng-repeat="n in range()" ng-class="{active: n == page}"> <a ng-click="search(n)" ng-if="n != page">{{n+1}}</a> <span ng-if="n == page">{{n+1}}</span> </li> <li><a ng-hide="page == pagesCount - 1" ng-click="search(pagePlus(1))">></a></li> <li><a ng-hide="page == pagesCount - 1" ng-click="search(pagesCount - 1)">>></a></li> </ul> </div> </div>
自定义的directive部分写在了spa/layout/customPager.directive.js里:
(function(app) { 'use strict'; app.directive('customPager', customPager); function customPager() { return { scope: { page: '@', pagesCount: '@', totalCount: '@', searchFunc: '&', customPath: '@' }, replace: true, restrict: 'E', templateUrl: '/scripts/spa/layout/pager.html', controller: ['$scope', function ($scope) { $scope.search = function (i) { if ($scope.searchFunc) { $scope.searchFunc({ page: i }); } }; $scope.range = function () { if (!$scope.pagesCount) { return []; } var step = 2; var doubleStep = step * 2; var start = Math.max(0, $scope.page - step); var end = start + 1 + doubleStep; if (end > $scope.pagesCount) { end = $scope.pagesCount; } var ret = []; for (var i = start; i != end; ++i) { ret.push(i); } return ret; }; $scope.pagePlus = function(count) { return +$scope.page + count; } }] } } })(angular.module('common.ui'));
点击Movie列表中某一项的图片来到明细页
html部分:
<a class="pull-left" ng-href="#/movies/{{movie.ID}}" title="View {{movie.Title}} details"> <img class="media-object" height="120" ng-src="../../Content/images/movies/{{movie.Image}}" alt="" /> </a>
在app.js中已经定义了路由规则:
.when("/movies/:id", { templateUrl: "scripts/spa/movies/details.html", controller: "movieDetailsCtrl", resolve: { isAuthenticated: isAuthenticated } })
来看API部分:
[Route("details/{id:int}")] public HttpResponseMessage Get(HttpRequestMessage request, int id) { return CreateHttpResponse(request, () => { HttpResponseMessage response = null; var movie = _moviesRepository.GetSingle(id); MovieViewModel movieVM = Mapper.Map<Movie, MovieViewModel>(movie); response = request.CreateResponse<MovieViewModel>(HttpStatusCode.OK, movieVM); return response; }); }
再来看明细部分的页面摘要:scripts/spa/movies/details.html
<h5>{{movie.Title}}</h5> <div class="panel-body" ng-if="!loadingMovie"> <a class="pull-right" ng-href="#/movies/{{movie.ID}}" title="View {{movie.Title}} details"> <img class="media-object" height="120" ng-src="../../Content/images/movies/{{movie.Image}}" alt="" /> </a> <h4 class="media-heading">{{movie.Title}}</h4> Directed by: <label>{{movie.Director}}</label><br /> Written by: <label>{{movie.Writer}}</label><br /> Produced by: <label>{{movie.Producer}}</label><br /> Rating: <span component-rating='{{movie.Rating}}'></span> <br /> <label class="label label-info">{{movie.Genre}}</label> <available-movie is-available="{{movie.IsAvailable}}"></available-movie> <a ng-href="{{movie.TrailerURI}}" >View Trailer <i class="fa fa-video-camera pull-right"></i></a> <a ng-href="#/movies/edit/{{movie.ID}}" class="btn btn-default">Edit movie </a> </div>
控制器部分为:scripts/spa/movies/movieDetailsCtrl.js
(function (app) { 'use strict'; app.controller('movieDetailsCtrl', movieDetailsCtrl); movieDetailsCtrl.$inject = ['$scope', '$location', '$routeParams', '$modal', 'apiService', 'notificationService']; function movieDetailsCtrl($scope, $location, $routeParams, $modal, apiService, notificationService) { $scope.pageClass = 'page-movies'; $scope.movie = {}; $scope.loadingMovie = true; $scope.loadingRentals = true; $scope.isReadOnly = true; $scope.openRentDialog = openRentDialog; $scope.returnMovie = returnMovie; $scope.rentalHistory = []; $scope.getStatusColor = getStatusColor; $scope.clearSearch = clearSearch; $scope.isBorrowed = isBorrowed; function loadMovie() { $scope.loadingMovie = true; apiService.get('/api/movies/details/' + $routeParams.id, null, movieLoadCompleted, movieLoadFailed); } function movieLoadCompleted(result) { $scope.movie = result.data; $scope.loadingMovie = false; } function movieLoadFailed(response) { notificationService.displayError(response.data); } loadMovie(); } })(angular.module('homeCinema'));
更新
在Movie的明细页给出了编辑按钮:
<a ng-href="#/movies/edit/{{movie.ID}}" class="btn btn-default">Edit movie </a>
而在app.js的路由设置中:
.when("/movies/edit/:id", { templateUrl: "scripts/spa/movies/edit.html", controller: "movieEditCtrl" })
来看编辑明细页摘要:scripts/spa/movies/edit.html
<img ng-src="../../Content/images/movies/{{movie.Image}}" class="avatar img-responsive" alt="avatar"> <input type="file" ng-file-select="prepareFiles($files)"> <form class="form-horizontal" role="form" novalidate angular-validator name="editMovieForm" angular-validator-submit="UpdateMovie()"> <input class="form-control" name="title" type="text" ng-model="movie.Title" validate-on="blur" required required-message="'Movie title is required'"> <select ng-model="movie.GenreId" class="form-control black" ng-options="option.ID as option.Name for option in genres" required></select> <input type="hidden" name="GenreId" ng-value="movie.GenreId" /> <input class="form-control" type="text" ng-model="movie.Director" name="director" validate-on="blur" required required-message="'Movie director is required'"> <input class="form-control" type="text" ng-model="movie.Writer" name="writer" validate-on="blur" required required-message="'Movie writer is required'"> <input class="form-control" type="text" ng-model="movie.Producer" name="producer" validate-on="blur" required required-message="'Movie producer is required'"> <input type="text" class="form-control" name="dateReleased" datepicker-popup="{{format}}" ng-model="movie.ReleaseDate" is-open="datepicker.opened" datepicker-options="dateOptions" ng-required="true" datepicker-append-to-body="true" close-text="Close" validate-on="blur" required required-message="'Date Released is required'" /> <span class="input-group-btn"> <button type="button" class="btn btn-default" ng-click="openDatePicker($event)"></button> </span> <input class="form-control" type="text" ng-model="movie.TrailerURI" name="trailerURI" validate-on="blur" required required-message="'Movie trailer is required'" ng-pattern="/^(https?://)?(www.youtube.com|youtu.?be)/.+$/" invalid-message="'You must enter a valid YouTube URL'"> <textarea class="form-control" ng-model="movie.Description" name="description" rows="3" validate-on="blur" required required-message="'Movie description is required'" /> <span component-rating="{{movie.Rating}}" ng-model="movie.Rating" class="form-control"></span> <input type="submit" class="btn btn-primary" value="Update" /> <a class="btn btn-default" ng-href="#/movies/{{movie.ID}}">Cancel</a> </form>
再来看编辑明细页控制器:scripts/spa/movies/movieEditCtrl.js
(function (app) { 'use strict'; app.controller('movieEditCtrl', movieEditCtrl); movieEditCtrl.$inject = ['$scope', '$location', '$routeParams', 'apiService', 'notificationService', 'fileUploadService']; function movieEditCtrl($scope, $location, $routeParams, apiService, notificationService, fileUploadService) { $scope.pageClass = 'page-movies'; $scope.movie = {}; $scope.genres = []; $scope.loadingMovie = true; $scope.isReadOnly = false; $scope.UpdateMovie = UpdateMovie; $scope.prepareFiles = prepareFiles; $scope.openDatePicker = openDatePicker; $scope.dateOptions = { formatYear: 'yy', startingDay: 1 }; $scope.datepicker = {}; var movieImage = null; //加载movie function loadMovie() { $scope.loadingMovie = true; apiService.get('/api/movies/details/' + $routeParams.id, null, movieLoadCompleted, movieLoadFailed); } //加载movie完成后加载genre function movieLoadCompleted(result) { $scope.movie = result.data; $scope.loadingMovie = false; //再加载genre loadGenres(); } function movieLoadFailed(response) { notificationService.displayError(response.data); } function genresLoadCompleted(response) { $scope.genres = response.data; } function genresLoadFailed(response) { notificationService.displayError(response.data); } function loadGenres() { apiService.get('/api/genres/', null, genresLoadCompleted, genresLoadFailed); } function UpdateMovie() { //上传图片 if (movieImage) { fileUploadService.uploadImage(movieImage, $scope.movie.ID, UpdateMovieModel); } else UpdateMovieModel(); } //实施更新 function UpdateMovieModel() { apiService.post('/api/movies/update', $scope.movie, updateMovieSucceded, updateMovieFailed); } function prepareFiles($files) { movieImage = $files; } function updateMovieSucceded(response) { console.log(response); notificationService.displaySuccess($scope.movie.Title + ' has been updated'); $scope.movie = response.data; movieImage = null; } function updateMovieFailed(response) { notificationService.displayError(response); } function openDatePicker($event) { $event.preventDefault(); $event.stopPropagation(); $scope.datepicker.opened = true; }; loadMovie(); } })(angular.module('homeCinema'));
对于上传图片,放在了一个自定义的服务中,在spa/services/fileUploadService.js中:
(function (app) { 'use strict'; app.factory('fileUploadService', fileUploadService); fileUploadService.$inject = ['$rootScope', '$http', '$timeout', '$upload', 'notificationService']; function fileUploadService($rootScope, $http, $timeout, $upload, notificationService) { $rootScope.upload = []; var service = { uploadImage: uploadImage } function uploadImage($files, movieId, callback) { //$files: an array of files selected for (var i = 0; i < $files.length; i++) { var $file = $files[i]; (function (index) { $rootScope.upload[index] = $upload.upload({ url: "api/movies/images/upload?movieId=" + movieId, // webapi url method: "POST", file: $file }).progress(function (evt) { }).success(function (data, status, headers, config) { // file is uploaded successfully notificationService.displaySuccess(data.FileName + ' uploaded successfully'); callback(); }).error(function (data, status, headers, config) { notificationService.displayError(data.Message); }); })(i); } } return service; } })(angular.module('common.core'));
在后端API中,对应的更新和上传图片action如下:
[HttpPost] [Route("update")] public HttpResponseMessage Update(HttpRequestMessage request, MovieViewModel movie) { return CreateHttpResponse(request, () => { HttpResponseMessage response = null; if (!ModelState.IsValid) { response = request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState); } else { var movieDb = _moviesRepository.GetSingle(movie.ID); if (movieDb == null) response = request.CreateErrorResponse(HttpStatusCode.NotFound, "Invalid movie."); else { movieDb.UpdateMovie(movie); movie.Image = movieDb.Image; _moviesRepository.Edit(movieDb); _unitOfWork.Commit(); response = request.CreateResponse<MovieViewModel>(HttpStatusCode.OK, movie); } } return response; }); } [MimeMultipart] [Route("images/upload")] public HttpResponseMessage Post(HttpRequestMessage request, int movieId) { return CreateHttpResponse(request, () => { HttpResponseMessage response = null; var movieOld = _moviesRepository.GetSingle(movieId); if (movieOld == null) response = request.CreateErrorResponse(HttpStatusCode.NotFound, "Invalid movie."); else { var uploadPath = HttpContext.Current.Server.MapPath("~/Content/images/movies"); var multipartFormDataStreamProvider = new UploadMultipartFormProvider(uploadPath); // Read the MIME multipart asynchronously Request.Content.ReadAsMultipartAsync(multipartFormDataStreamProvider); string _localFileName = multipartFormDataStreamProvider .FileData.Select(multiPartData => multiPartData.LocalFileName).FirstOrDefault(); // Create response FileUploadResult fileUploadResult = new FileUploadResult { LocalFilePath = _localFileName, FileName = Path.GetFileName(_localFileName), FileLength = new FileInfo(_localFileName).Length }; // update database movieOld.Image = fileUploadResult.FileName; _moviesRepository.Edit(movieOld); _unitOfWork.Commit(); response = request.CreateResponse(HttpStatusCode.OK, fileUploadResult); } return response; }); }
添加
在sidebar的html部分为:
<li><a ng-href="#/movies/add">Add movie</a></li>
在app.js的路由设置中:
.when("/movies/add", { templateUrl: "scripts/spa/movies/add.html", controller: "movieAddCtrl", resolve: { isAuthenticated: isAuthenticated } })
scripts/spa/movies/add.html部分与edit.html部分相似。
scripts/spa/movies/movieAddCtrl.js中:
(function (app) { 'use strict'; app.controller('movieAddCtrl', movieAddCtrl); movieAddCtrl.$inject = ['$scope', '$location', '$routeParams', 'apiService', 'notificationService', 'fileUploadService']; function movieAddCtrl($scope, $location, $routeParams, apiService, notificationService, fileUploadService) { $scope.pageClass = 'page-movies'; $scope.movie = { GenreId: 1, Rating: 1, NumberOfStocks: 1 }; $scope.genres = []; $scope.isReadOnly = false; $scope.AddMovie = AddMovie; $scope.prepareFiles = prepareFiles; $scope.openDatePicker = openDatePicker; $scope.changeNumberOfStocks = changeNumberOfStocks; $scope.dateOptions = { formatYear: 'yy', startingDay: 1 }; $scope.datepicker = {}; var movieImage = null; function loadGenres() { apiService.get('/api/genres/', null, genresLoadCompleted, genresLoadFailed); } function genresLoadCompleted(response) { $scope.genres = response.data; } function genresLoadFailed(response) { notificationService.displayError(response.data); } function AddMovie() { AddMovieModel(); } function AddMovieModel() { apiService.post('/api/movies/add', $scope.movie, addMovieSucceded, addMovieFailed); } function prepareFiles($files) { movieImage = $files; } function addMovieSucceded(response) { notificationService.displaySuccess($scope.movie.Title + ' has been submitted to Home Cinema'); $scope.movie = response.data; //添加movie成功后再上传图片 if (movieImage) { fileUploadService.uploadImage(movieImage, $scope.movie.ID, redirectToEdit); } else redirectToEdit(); } function addMovieFailed(response) { console.log(response); notificationService.displayError(response.statusText); } function openDatePicker($event) { $event.preventDefault(); $event.stopPropagation(); $scope.datepicker.opened = true; }; function redirectToEdit() { $location.url('movies/edit/' + $scope.movie.ID); } function changeNumberOfStocks($vent) { var btn = $('#btnSetStocks'), oldValue = $('#inputStocks').val().trim(), newVal = 0; if (btn.attr('data-dir') == 'up') { newVal = parseInt(oldValue) + 1; } else { if (oldValue > 1) { newVal = parseInt(oldValue) - 1; } else { newVal = 1; } } $('#inputStocks').val(newVal); $scope.movie.NumberOfStocks = newVal; console.log($scope.movie); } loadGenres(); } })(angular.module('homeCinema'));
后端对应的添加API部分:
[HttpPost] [Route("add")] public HttpResponseMessage Add(HttpRequestMessage request, MovieViewModel movie) { return CreateHttpResponse(request, () => { HttpResponseMessage response = null; if (!ModelState.IsValid) { response = request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState); } else { Movie newMovie = new Movie(); newMovie.UpdateMovie(movie); for (int i = 0; i < movie.NumberOfStocks; i++) { Stock stock = new Stock() { IsAvailable = true, Movie = newMovie, UniqueKey = Guid.NewGuid() }; newMovie.Stocks.Add(stock); } _moviesRepository.Add(newMovie); _unitOfWork.Commit(); // Update view model movie = Mapper.Map<Movie, MovieViewModel>(newMovie); response = request.CreateResponse<MovieViewModel>(HttpStatusCode.Created, movie); } return response; }); }
本系列结束~