Skip to content
Go back

用 AngularJS 组织广告配置后台页面

最近在做一类广告配置后台页面。它不像普通的内容展示页,页面上通常同时存在筛选条件、投放列表、素材信息、时间区间、上下线操作以及编辑表单。

如果用零散的 DOM 操作处理,页面状态很快会变得难以追踪:筛选以后列表是否重新加载,编辑完成以后哪一行需要更新,时间字段如何校验,重复点击保存怎样避免二次提交。

这类后台页面比较适合用 AngularJS 来组织。数据变化驱动视图更新,控制器处理页面行为,服务封装请求,指令处理重复出现的交互组件。

本文使用的是 AngularJS 1.x,下文简称 AngularJS。

一个广告配置页面包含哪些状态

先不急着写代码,可以先把页面中需要管理的信息分出来:

页面状态  -> 查询条件:广告位、投放状态、关键词、时间范围  -> 列表数据:当前页、总数、加载状态  -> 编辑数据:素材、链接、开始时间、结束时间  -> 操作状态:保存中、上下线中、错误提示

如果这些数据全部散落在点击回调中,任何需求变化都会影响多个位置。AngularJS 的价值之一,就是把页面展示所依赖的状态集中放在一个清晰的数据模型中。

使用 Controller 组织页面行为

列表页可以由一个控制器负责查询条件和交互入口:

<div ng-controller="AdListController as vm">  <form class="filter-bar" ng-submit="vm.search()">    <input      type="text"      ng-model="vm.query.keyword"      placeholder="广告名称或素材关键词">    <select ng-model="vm.query.status">      <option value="">全部状态</option>      <option value="online">投放中</option>      <option value="offline">已下线</option>    </select>    <button type="submit">查询</button>  </form>  <p ng-if="vm.loading">加载中...</p>  <p ng-if="!vm.loading && vm.items.length === 0">暂无广告配置</p>  <table ng-if="!vm.loading && vm.items.length > 0">    <tr ng-repeat="item in vm.items track by item.id">      <td>{{ item.name }}</td>      <td>{{ item.positionName }}</td>      <td>{{ item.startTime }} - {{ item.endTime }}</td>      <td>{{ item.statusText }}</td>      <td>        <button ng-click="vm.edit(item)">编辑</button>        <button ng-click="vm.toggleStatus(item)">          {{ item.status === 'online' ? '下线' : '上线' }}        </button>      </td>    </tr>  </table></div>

对应的控制器只维护与页面交互相关的状态:

angular.module('adConsole').controller('AdListController', function (AdService) {  var vm = this;  vm.query = {    keyword: '',    status: '',    page: 1,    pageSize: 20  };  vm.items = [];  vm.loading = false;  vm.search = function () {    vm.query.page = 1;    vm.load();  };  vm.load = function () {    vm.loading = true;    AdService.query(vm.query).then(function (result) {      vm.items = result.list;      vm.total = result.total;    }).finally(function () {      vm.loading = false;    });  };  vm.load();});

使用 controller as 的写法以后,模板中能够明确看出数据属于 vm,比在大页面里不断向 $scope 增加字段更容易维护。

请求逻辑放入 Service

后台页面往往有多个操作:查询列表、读取详情、保存编辑、上线、下线。请求地址和数据格式如果全部写在控制器中,控制器很快会变成接口与交互混在一起的大文件。

可以通过服务将请求能力集中封装:

angular.module('adConsole').factory('AdService', function ($http) {  return {    query: function (params) {      return $http.get('/api/ad/list', {        params: params      }).then(function (response) {        return response.data;      });    },    detail: function (id) {      return $http.get('/api/ad/detail', {        params: { id: id }      }).then(function (response) {        return response.data;      });    },    save: function (formData) {      return $http.post('/api/ad/save', formData).then(function (response) {        return response.data;      });    },    toggleStatus: function (id, status) {      return $http.post('/api/ad/status', {        id: id,        status: status      }).then(function (response) {        return response.data;      });    }  };});

控制器关心的是“用户发起了什么操作,以及页面如何反馈”,服务则关心“如何和接口交互”。当接口路径或返回结构变化时,不需要把整个页面翻一遍。

编辑表单要把校验放在提交之前

广告配置里,时间和素材是比较容易出错的部分。例如结束时间必须晚于开始时间,跳转地址不能为空,素材尺寸必须符合广告位要求。

AngularJS 表单可以先处理基础必填校验:

<form name="adForm" ng-submit="vm.save(adForm)" novalidate>  <label>    广告名称    <input name="name" ng-model="vm.form.name" required>  </label>  <span ng-if="adForm.name.$touched && adForm.name.$error.required">    请输入广告名称  </span>  <label>    跳转地址    <input name="link" ng-model="vm.form.link" required>  </label>  <label>    开始时间    <input ng-model="vm.form.startTime" required>  </label>  <label>    结束时间    <input ng-model="vm.form.endTime" required>  </label>  <button type="submit" ng-disabled="vm.saving">    {{ vm.saving ? '保存中...' : '保存' }}  </button></form>

需要结合业务判断的规则,则在提交入口中统一处理:

vm.save = function (form) {  if (form.$invalid) {    return;  }  if (vm.form.endTime <= vm.form.startTime) {    vm.errorMessage = '结束时间需要晚于开始时间';    return;  }  vm.saving = true;  AdService.save(vm.form).then(function () {    vm.closeEditor();    vm.load();  }).finally(function () {    vm.saving = false;  });};

保存按钮在请求完成前禁用,可以避免用户连续提交产生重复配置。

重复交互适合封装为 Directive

广告后台中常见的一类重复控件是状态标签、图片预览和时间范围选择。如果每个页面各自处理 DOM 与事件,交互细节会越来越不一致。

例如,一个素材预览指令可以封装图片展示和加载失败处理:

angular.module('adConsole').directive('adPreview', function () {  return {    restrict: 'E',    scope: {      src: '=',      width: '=',      height: '='    },    template:      '<div class="ad-preview">' +        '<img ng-src="{{ src }}" alt="广告素材">' +        '<p>{{ width }} x {{ height }}</p>' +      '</div>'  };});

页面使用时只传入数据:

<ad-preview  src="vm.form.imageUrl"  width="vm.form.imageWidth"  height="vm.form.imageHeight"></ad-preview>

指令不应该承担保存广告或者请求列表这样的业务动作,它更适合封装可复用的展示和输入交互。

列表操作需要保留稳定反馈

后台中的上线、下线操作通常会直接影响当前列表行。操作成功后可以局部更新状态,也可以重新请求列表。

如果状态更新会影响排序、筛选结果或者多个字段,重新加载当前查询条件往往更稳妥:

vm.toggleStatus = function (item) {  var nextStatus = item.status === 'online' ? 'offline' : 'online';  item.operating = true;  AdService.toggleStatus(item.id, nextStatus).then(function () {    vm.load();  }).finally(function () {    item.operating = false;  });};

如果只是非常简单的字段变更,可以直接修改当前行,减少一次列表请求。这里不一定有唯一正确的答案,关键是页面展示不能与服务端最终状态产生长时间偏差。

小结

AngularJS 很适合这类状态密集的管理后台页面:

后台页面并不追求复杂动画,但它非常在意信息是否清楚、操作是否可预期、状态是否一致。把这些职责拆开以后,广告配置需求继续增长时,页面也不会很快陷入一堆难以整理的事件回调中。


Share this post on: