评论管理

经过之前的文章管理和分类管理两个功能的开发过程,我们发现基本上都是相同的套路,而且对于任何一个业务最终都还是这些基础的增删改查

这里的评论管理我们就不在按照之前的实现方式(传统的动态网站方式)去完成了,取而代之的是 AJAX 方式。

咱们这个过程的设计就体现了 Web 技术的大致发展历史,了解并掌握这些有助于我们更好的使用当下最新最优的方案完成当下应用的开发。

前提说明

这里我们最后一次对比一下传统的动态网站方式和 AJAX 方式之间的差异:

传统方式

Created with Raphaël 2.2.0客户端客户端服务端服务端发送一个页面请求(GET)返回一个完整页面(包含数据)响应(HTML)提交一个表单请求(POST)返回一个完整页面(包含数据)响应(HTML)

AJAX 方式

Created with Raphaël 2.2.0客户端客户端服务端服务端发送一个页面请求(GET)不用查询数据组织页面直接返回静态 HTML返回一个空的页面结构(不包含数据)响应(HTML)发送一个异步请求(GET)返回数据响应(JSON / XML)通过 DOM 操作将数据渲染到空的页面结构上提交一个异步的表单请求(POST)返回数据响应(JSON / XML)通过 DOM 操作将数据渲染到空的页面结构上

通过 AJAX 方式实现评论管理

返回空页面结构

早在开始开发的时候,我们就已经将评论管理的静态页面整合进来了,这里我们只需要简单调整一下,包括:

  1. 删除静态页开发时页面上写死的假数据
  2. 该加 id 的加 id,该加 name 的加 name

既然是返回空页面,那么页面执行过程中就不需要有 PHP 代码的执行了,那么 PHP 对于这个过程就没有意义了。

提问:这里的评论管理页是应该用 html 文件,还是 php 文件?

🚩 源代码: step-58

数据接口

既然是通过 AJAX 获取数据,然后在通过 DOM 操作渲染数据,我们首先第一前提就是要有一个可以获取评论数据的接口,那么接下来我们就需要开发一个可以返回评论数据的接口。

设计结构的特性

从使用者的角度考虑每一个所需功能,反推出来对接口的要求,然后具体实现每个要求,这就是所谓的逆向工程。

对于评论管理页面,我们的需求是:

  1. 可以分页查看评论数据列表(作者、评论、文章标题、评论时间、评论状态)
  2. 可以通过分页导航访问特定分页的数据,
  3. 可以通过操作按钮批准、拒绝、删除每一条评论

根据需求得知,这个功能开发过程中需要三个的接口(endpoint),我们创建三个 php 文件:

  1. comment-list.php: 分页加载评论数据
  2. comment-delete.php: 删除评论
  3. comment-status.php: 改变评论状态

名词解释:对于 Web API,我们把每一个接入点称之为 endpoint

comment-list.php

分页查询数据的逻辑可以参考文章管理模块的数据加载

 
1
// 处理分页参数
2
// ========================================
3
4
// 页码
5
$page = isset($_GET['p']) && is_numeric($_GET['p']) ? intval($_GET['p']) : 1;
6
7
// 检查页码最小值
8
if ($page <= 0) {
9
  header('Location: /admin/comment-list.php?p=1');
10
  exit;
11
}
12
13
// 页大小
14
$size = isset($_GET['s']) && is_numeric($_GET['s']) ? intval($_GET['s']) : 20;
15
16
// 查询总条数
17
$total_count = intval(xiu_query('select count(1) from comments
18
inner join posts on comments.post_id = posts.id')[0][0]);
19
20
// 计算总页数
21
$total_pages = ceil($total_count / $size);
22
23
// 检查页码最大值
24
if ($page > $total_pages) {
25
  // 跳转到最后一页
26
  header('Location: /admin/comment-list.php?p=' . $total_pages);
27
  exit;
28
}
29
30
// 查询数据
31
// ========================================
32
33
// 分页查询评论数据
34
$comments = xiu_query(sprintf('select
35
  comments.*, posts.title as post_title
36
from comments
37
inner join posts on comments.post_id = posts.id
38
order by comments.created desc
39
limit %d, %d', ($page - 1) * $size, $size));
40
41
// 响应 JSON
42
// ========================================
43
44
// 设置响应类型为 JSON
45
header('Content-Type: application/json');
46
47
// 输出 JSON
48
echo json_encode(array(
49
  'success' => true,
50
  'data' => $comments,
51
  'total_count' => $total_count
52
));

🚩 源代码: step-59

comment-delete.php

参考分类删除或者文章删除。

 
1
// 设置响应类型为 JSON
2
header('Content-Type: application/json');
3
4
if (empty($_GET['id'])) {
5
  // 缺少必要参数
6
  exit(json_encode(array(
7
    'success' => false,
8
    'message' => '缺少必要参数'
9
  )));
10
}
11
12
// 拼接 SQL 并执行
13
$affected_rows = xiu_execute(sprintf('delete from comments where id in (%s)', $_GET['id']));
14
15
// 输出结果
16
echo json_encode(array(
17
  'success' => $affected_rows > 0
18
));

🚩 源代码: step-60

comment-status.php

 
1
// 设置响应类型为 JSON
2
header('Content-Type: application/json');
3
4
// 不是说 POST 方式就不能使用 GET 传参数,不要固化思维
5
if (empty($_GET['id']) || empty($_POST['status'])) {
6
  // 缺少必要参数
7
  exit(json_encode(array(
8
    'success' => false,
9
    'message' => '缺少必要参数'
10
  )));
11
}
12
13
// 拼接 SQL 并执行
14
$affected_rows = xiu_execute(sprintf("update comments set status = '%s' where id in (%s)", $_POST['status'], $_GET['id']));
15
16
// 输出结果
17
echo json_encode(array(
18
  'success' => $affected_rows > 0
19
));

🚩 源代码: step-61

有了接口过后,我们就可以通过在页面中执行 AJAX 操作调用这些接口,实现相对应的功能了。

评论数据加载

页面加载完成过后,发送异步请求获取评论数据

 
1
$.get('/admin/comment-list.php', { p: 1, s: 30 }, function (res) {
2
  console.log(res)
3
  // => { success: true, data: [ ... ], total_count: 100 }
4
})

将数据渲染(客户端渲染)到表格中:

 
1
var $alert = $('.alert')
2
var $tbody = $('tbody')
3
4
// 页面加载完成过后,发送异步请求获取评论数据
5
$.get('/admin/comment-list.php', { p: 1, s: 30 }, function (res) {
6
  console.log(res)
7
  // => { success: true, data: [ ... ], total_count: 100 }
8
  if (!res.success) {
9
    // 加载失败 提示消息 并结束运行
10
    return $alert.text(res.message)
11
  }
12
13
  // 将数据渲染到表格中
14
  $(res.data).each(function (i, item) {
15
    // 每一个数据对应一个 tr
16
    $tbody.append('<tr class="' + '' + '">' +
17
    '  <td class="text-center"><input type="checkbox"></td>' +
18
    '  <td>' + item.author + '</td>' +
19
    '  <td>' + item.content + '</td>' +
20
    '  <td>《' + item.post_title + '》</td>' +
21
    '  <td>' + item.created + '</td>' +
22
    '  <td>' + item.status + '</td>' +
23
    '  <td class="text-center">' +
24
    '    <a href="javascript:;" class="btn btn-info btn-xs">批准</a>' +
25
    '    <a href="javascript:;" class="btn btn-danger btn-xs">删除</a>' +
26
    '  </td>' +
27
    '</tr>')
28
  })
29
})

🚩 源代码: step-62

不用往下写了,一旦涉及到这种数据加载渲染的问题,就会涉及到大量的字符串拼接问题,费劲还容易错,总之很不爽。

之前在服务端渲染数据的时候,没有太多这种感觉,而现在到了客户端渲染就十分恶心,根本原因是因为我们的方法过于原始,对于简单的数据渲染还是可以接受的,但是一旦数据复杂了,结构复杂了,过程就十分恶心,而后端使用的实际上是一种“套模板”过程。

脑子稍微灵光一点的同学就应该想得明白:作为一个天天骑自行车上下班的人看着旁边的人都骑摩托车,久而久之这个骑自行车的也会换成摩托车。

当然这里只是打个比方,可以体会我想表达的意思:作为一个积极的人,在明知道有更好方案的情况下是不会一直使用自己以前的老旧方案的,这是社会进步的核心,也是技术进步的核心。

前端也有模板引擎,而且从使用上来说,更多更好更方便。

换而言之,模板引擎的本质其实就是各种恶心的字符串操作。 而这里我为什么扯了这么多,主要目的是希望能够有所体会,有所感悟,不要荒废了你的思考能力。

这里我们借助一个非常轻量的模板引擎 jsrender 解决以上问题,模板引擎的使用套路也都类似:

  1. 引入模板引擎库文件
  2. 准备一个模板
  3. 准备一个需要渲染的数据
  4. 调用一个模板引擎库提供的方法,把数据通过模板渲染成 HTML

载入模板引擎库

 
1
<script src="/static/assets/vendors/jsrender/jsrender.js"></script>

准备模板

 
1
<script id="comment_tmpl" type="text/x-jsrender">
2
  <tr class="danger">
3
    <td class="text-center"><input type="checkbox"></td>
4
    <td>大大</td>
5
    <td>楼主好人,顶一个</td>
6
    <td>《Hello world》</td>
7
    <td>2016/10/07</td>
8
    <td>未批准</td>
9
    <td class="text-center">
10
      <a href="javascript:;" class="btn btn-info btn-xs">批准</a>
11
      <a href="javascript:;" class="btn btn-danger btn-xs">删除</a>
12
    </td>
13
  </tr>
14
</script>

提问:一般模板引擎都要求把模板定义在 <script> 标签中,为什么?

调用模板方法

 
1
var html = $('#comment_tmpl').render(data)
2
// html => 渲染后的结果
3
// 设置到页面中
4
$tbody.html(html)

借助模板语法输出变量

 
1
<script id="comment_tmpl" type="text/x-jsrender">
2
  {{if success}}
3
  {{for data}}
4
  <tr class="{{: status === 'held' ? 'warning' : status === 'rejected' ? 'danger' : '' }}">
5
    <td class="text-center"><input type="checkbox" data-id="{{: id }}"></td>
6
    <td>{{: author }}</td>
7
    <td>{{: content }}</td>
8
    <td>{{: post_title }}</td>
9
    <td>{{: created}}</td>
10
    <td>{{: status === 'held' ? '待审' : status === 'rejected' ? '拒绝' : '准许' }}</td>
11
    <td class="text-center">
12
      {{if status ===  'held'}}
13
      <button class="btn btn-info btn-xs" data-id="{{: id }}">批准</button>
14
      <button class="btn btn-warning btn-xs" data-id="{{: id }}">拒绝</button>
15
      {{/if}}
16
      <button class="btn btn-danger btn-xs" data-id="{{: id }}">删除</button>
17
    </td>
18
  </tr>
19
  {{/for}}
20
  {{else}}
21
  <tr>
22
    <td colspan="7">{{: message }}</td>
23
  </tr>
24
  {{/if}}
25
</script>

🚩 源代码: step-63

分页组件展示

我们之前写的生成分页 HTML 是在服务端渲染分页组件,只能作用在服务端渲染数据的情况下。但是当下的情况我们采用的是客户端渲染的方案,自然就用不了之前的代码了,但是思想是相通的,我们仍然可以按照之前的套路来实现,只不过是在客户端,借助于 JavaScript 实现。

✏️ 作业: 实现一个 JavaScript 版本的分页组件

这里我们就不自己再写了,前端行业最大的特点就是轮子多,实际开发过程中我们多是使用已有的轮子。

思考: 为什么不要造轮子,造轮子有什么优点,又有什么缺点?

这里使用的是 twbs-pagination,使用方法就不在这里赘述了。

 
1
// 页面加载完成过后,发送异步请求获取评论数据
2
$.get('/admin/comment-list.php', { p: 1, s: size }, function (res) {
3
  // 通过模板引擎渲染数据
4
  var html = $tmpl.render(res)
5
6
  // 设置到页面中
7
  $tbody.html(html)
8
9
  // 分页组件
10
  $pagination.twbsPagination({
11
    totalPages: Math.ceil(res.total_count / size),
12
    onPageClick: function (event, page) {
13
      // 页码发生变化时执行
14
      console.log(page)
15
    }
16
  })
17
})

🚩 源代码: step-64

我们后续都会在 onPageClick 加载指定页的数据:

 
1
// 页面加载完成过后,发送异步请求获取评论数据
2
$.get('/admin/comment-list.php', { p: 1, s: size }, function (res) {
3
  // 通过模板引擎渲染数据
4
  var html = $tmpl.render(res)
5
6
  // 设置到页面中
7
  $tbody.html(html)
8
9
  // 分页组件
10
  $pagination.twbsPagination({
11
    initiateStartPageClick: false, // 否则 onPageClick 第一次就会触发
12
    totalPages: Math.ceil(res.total_count / size),
13
    onPageClick: function (e, page) {
14
      $.get('/admin/comment-list.php', { p: page, s: size }, function (res) {
15
        // 通过模板引擎渲染数据
16
        var html = $tmpl.render(res)
17
        // 设置到页面中
18
        $tbody.html(html)
19
      })
20
    }
21
  })
22
})

🚩 源代码: step-65

删除评论

如果是采用同步的方式,则与文章或分类管理的删除相同,但是此处我们的方案是采用 AJAX 方式。

万变不离其宗,想要删除掉评论,客户端肯定是做不到的,因为数据在服务端。可以通过客户端发送一个请求(信号)到服务端,服务端执行删除操作,服务端业务已经实现,现在的问题就是客户端发请求的问题。

给删除按钮绑定点击事件

常规思路

  1. 为删除按钮添加一个 btn-delete 的 class
  2. 为所有 btn-delete 注册点击事件
 
1
$('.btn-delete').on('click', function () {
2
  console.log('btn delete clicked')
3
})

但是经过测试发现,在点击删除按钮后控制台不会输出任何内容,也就是说按钮的点击事件没有执行。

提问:为什么按钮的点击事件不会执行

问题的答案也非常简单:当执行注册事件代码时,表格中的数据还没有初始化完成,那么通过 $('.btn-delete') 就不会选择到后来界面上的删除按钮元素,自然也就没办法注册点击事件了。

解决办法

  1. 控制注册代码的执行时机;
  2. 另外一种事件方式:委托事件;

http://www.jquery123.com/on/#direct-and-delegated-events

 
1
// 删除评论
2
$tbody.on('click', '.btn-delete', function () {
3
  console.log('btn delete clicked')
4
})

🚩 源代码: step-66

发送删除评论的异步请求

点击事件执行 -> 发送异步请求 -> 移除当前点击按钮所属行

 
1
$tbody.on('click', '.btn-delete', function () {
2
  var $tr = $(this).parent().parent()
3
  var id = parseInt($tr.data('id'))
4
  $.get('/admin/comment-delete.php', { id: id }, function (res) {
5
    res.success && $tr.remove()
6
  })
7
})

🚩 源代码: step-67

个人认为删除成功过后,不应该单单从界面上的表格中移除当前行,而是重新加载当前页数据。

我们重新调整一下代码:

 
1
var $alert = $('.alert')
2
var $tbody = $('tbody')
3
var $tmpl = $('#comment_tmpl')
4
var $pagination = $('.pagination')
5
6
// 页大小
7
var size = 30
8
// 当前页码
9
var currentPage = 1
10
11
/**
12
 * 加载指定页数据
13
 */
14
function loadData () {
15
  $.get('/admin/comment-list.php', { p: currentPage, s: size }, function (res) {
16
    // 通过模板引擎渲染数据
17
    var html = $tmpl.render(res)
18
    // 设置到页面中
19
    $tbody.html(html)
20
  })
21
}
22
23
// 页面加载完成过后,发送异步请求获取评论数据
24
$.get('/admin/comment-list.php', { p: 1, s: size }, function (res) {
25
  console.log(res)
26
  // => { success: true, data: [ ... ], total_count: 100 }
27
28
  // 通过模板引擎渲染数据
29
  var html = $tmpl.render(res)
30
31
  // 设置到页面中
32
  $tbody.html(html)
33
34
  // 分页组件
35
  $pagination.twbsPagination({
36
    initiateStartPageClick: false, // 否则 onPageClick 第一次就会触发
37
    totalPages: Math.ceil(res.total_count / size),
38
    onPageClick: function (e, page) {
39
      currentPage = page
40
      loadData()
41
    }
42
  })
43
})
44
45
// 删除评论
46
$tbody.on('click', '.btn-delete', function () {
47
  var $tr = $(this).parent().parent()
48
  var id = parseInt($tr.data('id'))
49
  $.get('/admin/comment-delete.php', { id: id }, function (res) {
50
    res.success && loadData()
51
  })
52
})

🚩 源代码: step-68

修改评论状态

 
1
$tbody.on('click', '.btn-edit', function () {
2
  var id = parseInt($(this).parent().parent().data('id'))
3
  var status = $(this).data('status')
4
  $.post('/admin/comment-status.php?id=' + id, { status: status }, function (res) {
5
    res.success && loadData()
6
  })
7
})

🚩 源代码: step-69

批量操作

批量操作显示

当选中了一个或一个以上的行时,显示批量操作按钮:

 
1
var $btnBatch = $('.btn-batch')
2
3
// 选中项集合
4
var checkedItems = []
5
6
// 批量操作按钮
7
$tbody.on('change', 'td > input[type=checkbox]', function () {
8
  var id = parseInt($(this).parent().parent().data('id'))
9
  if ($(this).prop('checked')) {
10
    checkedItems.push(id)
11
  } else {
12
    checkedItems.splice(checkedItems.indexOf(id), 1)
13
  }
14
  checkedItems.length ? $btnBatch.fadeIn() : $btnBatch.fadeOut()
15
})

🚩 源代码: step-70

全选 / 全不选

点击表头中的复选框,切换表格中全部数据选中状态

 
1
// 全选 / 全不选
2
$('th > input[type=checkbox]').on('change', function () {
3
  var checked = $(this).prop('checked')
4
  $('td > input[type=checkbox]').prop('checked', checked).trigger('change')
5
})

🚩 源代码: step-71

批量操作按钮点击

点击不同按钮,执行不同请求

 
1
// 批量操作
2
$btnBatch
3
  // 批准
4
  .on('click', '.btn-info', function (e) {
5
    $.post('/admin/comment-status.php?id=' + checkedItems.join(','), { status: 'approved' }, function (res) {
6
      res.success && loadData()
7
    })
8
  })
9
  // 拒绝
10
  .on('click', '.btn-warning', function (e) {
11
    $.post('/admin/comment-status.php?id=' + checkedItems.join(','), { status: 'rejected' }, function (res) {
12
      res.success && loadData()
13
    })
14
  })
15
  // 删除
16
  .on('click', '.btn-danger', function (e) {
17
    $.get('/admin/comment-delete.php', { id: checkedItems.join(',') }, function (res) {
18
      res.success && loadData()
19
    })
20
  })

🚩 源代码: step-72

✏️ 作业: 解决刷新过后继续加载指定页码下的数据

🚩 源代码: step-73