项目演示地址:https://hujiyi.github.io/acme-world-web/


论坛话题详情页中,首先要显示的是话题有关的内容,比如标题、作者、时间、内容等各种信息

除此以外,还要提供一个用于发表评论的表单,以及显示当前话题已有的评论内容。

评论需要使用一个单独的表(Class) 来进行存放,同时,还要记录是对哪个话题进行的评论。

查询指定 id 的话题,和增加查看次数

进入详情页后,要先把指定 id 的话题显示出来,同时这个话题的查看次数也在增加,相应的功能可以添加到 TopicService 中。

打开文件src/api/service/topic_service.js, 修改其内容如以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import LC from 'leancloud-storage';  // 导入 leancloud 数据存储模块
import BaseService from './base_service'; // 导入访问 leancloud 数据存储的基类


const TABLE_NAME = 'Topic'; // leancloud 中存储数据的表名(Class)

/**
* 对leancloud应用中 名为 "Topic" 的 Class 进行数据操作的类
*/
class TopicService extends BaseService {
constructor(table_name) {
// 调用父类的构造函数
super(table_name);
// 当前子类的属性 this.TABLE_NAME
this.TABLE_NAME = table_name;
}
}

/**
* 查询指定 id 的论坛话题
* @param {*} id
*/
TopicService.prototype.getTopicById = async function (id) {
try {
let query = new LC.Query(this.TABLE_NAME);
// include("createdBy", "lastEditor") 或者 include(["createdBy", "lastEditor"]) 都可以
query.include(["createdBy", "lastEditor"]);
let response = await query.get(id);
console.log(response);
return {
"status_code": "ok",
"reslut": response,
}
} catch (e) {
console.log('查询数据错误:', e.code, e);
return {
"status_code": e.code,
"reslut": e,
};
}
}

/**
* 指定 id 的话题 查看次数加1
* @param {*} id
* @returns
*/
TopicService.prototype.viewsInc = async function (id) {
try {
let topic = LC.Object.createWithoutData(this.TABLE_NAME, id);
// 原子操作 来增加或减少一个属性内保存的数字
topic.increment('views', 1);
let response = await topic.save();
return response;
} catch (e) {
console.log('修改数据错误:', e.code, e);
}
return null;
}


// 导出子类时使用 new 直接进行实例化
export default new TopicService(TABLE_NAME);

添加用于评论的 CommentService

评论的内容要保存在另外一个表中,这里使用一个CommentService 类来实现对评论内容的管理。

创建文件 src/api/service/comment_service.js, 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import LC from 'leancloud-storage';  // 导入 leancloud 数据存储模块
import BaseService from './base_service'; // 导入访问 leancloud 数据存储的基类


const TABLE_NAME = 'Comment'; // leancloud 中存储数据的表名(Class)

/**
* 对leancloud应用中 名为 "Comment" 的 Class 进行数据操作的类
*/
class CommentService extends BaseService {
constructor(table_name) {
// 调用父类的构造函数
super(table_name);
// 当前子类的属性 this.TABLE_NAME
this.TABLE_NAME = table_name;
}
}

/**
* 发表评论
* @param {*} topicId 被评论的话题 id
* @param {*} content 评论的内容
* @returns
*/
CommentService.prototype.newComment = async function (topicId, content) {
try {
// 根据 topicId 找出被评论的话题
let topic = LC.Object.createWithoutData('Topic', topicId);
// 创建保存评论内容的 Class
let Collection = LC.Object.extend(this.TABLE_NAME);
// 创建评论的内容对象
let query = new Collection({
"topic": topic, // 评论的话题
"content": content, // 评论的内容
"invisible": false, // 不可见,如果为 true 就隐藏该评论
"createdBy": await this.currentUser(), // 评论者
});
let response = await query.save(); // 保存评论数据
if (response) {
topic.increment('reply', 1); // 评论数加一
topic.set('lastRepliedAt', new Date()); // 更新最后评论时间
let resp = await topic.save(); // 保存
console.log('发了评论的 topic :', resp);
return response;
}
} catch (e) {
console.log('添加评论失败:', e.code, e);
}
return null;
}

/**
* 查询指定 id 的话题的评论内容
* @param {*} topicId 话题 id
* @param {*} limit 每页大小
* @param {*} skip 跳过记录数
* @param {*} sort_field 单个排序字段名(降序加负号) 默认按创建时间升序
*/
CommentService.prototype.getCommentByTopic = async function (topicId, limit = 5, skip = 0, sort_field = 'createdAt') {
try {
let query = new LC.Query(this.TABLE_NAME);
const topic = LC.Object.createWithoutData('Topic', topicId); // 根据 id 获取话题
query.include('createdBy');
query.equalTo('topic', topic); // 关系查询
// 排序属性: this.isMinus() 是父类中定义的方法,判断排序的字段是否 负号 开头
if (this.isMinus(sort_field)) {
console.log(sort_field, sort_field.substr(1));
// 去掉负号后的字段名降序
query.descending(sort_field.substr(1));
}
else {
query.ascending(sort_field); // 升序
}

let total = await query.count(); // 返回记录总数
// 分页查询参数
let response = await query.limit(limit).skip(skip).find();
// let response = await query.find();
console.log('getCommentByTopic:', total, response);
return {
"status_code": "ok",
"totalCount": total,
"reslut": response,
}
} catch (e) {
console.log('查询数据错误:', e.code, e);
return {
"status_code": e.code,
"totalCount": 0,
"reslut": e,
};
}
}

// 导出子类时使用 new 直接进行实例化
export default new CommentService(TABLE_NAME);

s打开文件src/views/home/pages/Detail.vue, 编辑文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
<template>
<div>
<p></p>
<el-row type="flex" justify="center" align="middle">
<el-col :lg="18" :md="20" :sm="24">
<el-card>
<div>
<span class="title">话题:{{ topic.title }}</span>
<br />
<!-- 异步数据先显示初始数据,再显示带数据的数据 -->
<!-- 会导致三层表达式 a.b.c 渲染时会报错,是因为第一次渲染时还没有值,后来有值渲染成功 -->
<!-- 可以不理睬,不想看错误信息也可以加 v-if="topic.createdBy" 来解决 -->
<span class="info" v-if="topic.createdBy">
{{ topic.createdBy.username }} -
{{ topic.createdAt | datetimeFormat }}
</span>
</div>
<el-divider></el-divider>
<p v-html="topic.content"></p>

<el-divider></el-divider>
<div>
<p>
<span style="font-size:18px;">
<i class="fa fa-comments-o fa-lg"></i>
评论
</span>
<span class="mx-2">共 {{ replyCount }} 条评论</span>
</p>
</div>

<el-row type="flex">
<el-col style="width: 100px;">
<div class="comment-user">
<el-avatar style="background-color: red;">
<i class="fa fa-user fa-4x"></i>
</el-avatar>
<span class="my-2">{{ currentUser }}</span>
</div>
</el-col>
<el-col>
<el-form>
<el-form-item label="评论(支持 Markdown 语法)" prop="desc">
<el-input
type="textarea"
rows="5"
v-model="content"
placeholder="输入评论内容"
:disabled="!currentUser"
></el-input>
</el-form-item>
<el-form-item>
<div class="horiz-container">
<span v-if="!currentUser" style="color:red;">登录后发表评论</span>
<div class="spacer"></div>
<el-button :disabled="!currentUser">预览</el-button>
<el-button
:disabled="!currentUser"
type="primary"
@click="newComment"
>评论</el-button>
</div>
</el-form-item>
</el-form>
</el-col>
</el-row>
<el-row>
<el-col>
<div v-for="(item, index) in comments" :key="index">
<p class="author">{{ item.createdBy.username }}</p>
<p v-if="!item.invisible" v-html="item.content"></p>
<p v-else>***该评论内容已被隐藏***</p>
<div class="horiz-container">
<span class="info">{{ item.createdAt | datetimeFormat }}</span>
<div class="spacer"></div>
<el-link class="mx-2">
<i class="fa fa-thumbs-o-up"></i>
</el-link>
<el-link class="mx-2">
<i class="fa fa-thumbs-o-down"></i>
</el-link>
<el-link class="mx-2">举报</el-link>
<el-link class="mx-2">隐藏</el-link>
</div>

<el-divider></el-divider>
</div>
</el-col>
</el-row>
<div class="horiz-container pagination">
<div class="spacer"></div>
<el-pagination
background
@size-change="getCommentByTopic($route.params.id)"
@current-change="getCommentByTopic($route.params.id)"
layout="total, sizes, prev, pager, next, jumper"
:total="replyCount"
:page-sizes="[5, 10, 20, 30]"
:page-size.sync="pageSize"
:current-page.sync="currentPage"
></el-pagination>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>

<script>
import CommentService from '../../../api/service/comment_service';
import TopicService from '../../../api/service/topic_service';
import moment from 'moment';
import { marked } from 'marked'
import hljs from 'highlight.js'; // 需要安装 highlight.js, 例如:npm i highlight.js
import 'highlight.js/styles/github-dark.css'; // 代码高亮样式

export default {
name: 'Detail',
data: () => ({
topic: {},
comments: [],
replyCount: 0,
content: '',
pageSize: 5,
currentPage: 1,
currentUser: null,
dialog: false,
username: '',
password: '',
}),
async mounted() {
this.currentUser = localStorage.getItem('currentUser');
// marked.js 初始化
marked.setOptions({
renderer: new marked.Renderer(),
highlight: function (code) {
return hljs.highlightAuto(code).value;
// return hljs.highlightBlock(code);
},
"gfm": true,// 启动Github样式的Markdown
"breaks": true,//支持Github换行符,必须打开gfm选项
"tables": true, //支持Github表格,必须打开gfm选项
"extensions": null,
"headerIds": true,
"headerPrefix": "",
"highlight": null,
"langPrefix": "language-",
"mangle": true,
"pedantic": false,// 只解析符合markdown.pl定义的,不修正markdown的错误
"sanitize": false, // 原始输出,忽略HTML标签
"sanitizer": null,
"silent": false,
"smartLists": false, //优化列表输出
"smartypants": false,//使用更为时髦的标点,比如在引用语法中加入破折号。
"tokenizer": null,
"walkTokens": null,
"xhtml": false
});
let topicId = this.$route.params.id;
this.viewInc(topicId);
this.getTopicById(topicId);
this.getCommentByTopic(topicId);
},
methods: {
async getTopicById(topicId) {
let response = await TopicService.getTopicById(topicId);
if (response.status_code === 'ok') {
this.topic = this.toJson(response.reslut);
} else {
this.$message({
message: '出现异常,无法获取数据',
type: 'error'
});
}
},

async getCommentByTopic(topicId) {
this.comments = [];
let skip = (this.currentPage - 1) * this.pageSize;
let response = await CommentService.getCommentByTopic(topicId, this.pageSize, skip);
if (response.status_code === 'ok') {
this.comments = response.reslut.map(item => {
return {
id: item.id,
topicId: item.get('topic').id,
content: this.markdown(item.get('content')),
invisible: item.get('visible'),
createdAt: item.createdAt,
updatedAt: item.updatedAt,
createdBy: item.get("createdBy")
? this.getUser(item.get("createdBy"))
: "",
}
});
this.replyCount = response.totalCount;
} else {
this.$message({
message: '出现异常,无法获取评论数据',
type: 'error'
});
}

},


async newComment() {
let content = this.content.trim();
if (content) {
let item = await CommentService.newComment(this.topic.id, content);
if (item) {
console.log(item);
this.content = '';
// 把新评论添加到当前评论的前面。
this.comments.push({
id: item.id,
topicId: item.get('topic').id,
content: this.markdown(item.get('content')),
invisible: item.get('visible'),
createdAt: item.createdAt,
updatedAt: item.updatedAt,
createdBy: item.get("createdBy")
? this.getUser(item.get("createdBy"))
: "",
});
this.replyCount++;
this.$message({
type: 'success',
message: `评论发表成功`,
});
} else {
this.$message({
message: '出现错误, 无法完成评论',
type: 'error'
});
}
}

},


// 查看次数加1
async viewInc(id) {
await TopicService.viewsInc(id);
},

toLogin() {
console.log('aaa');
if (!this.currentUser) {
this.dialog = true;
}
},


// 获取用户中各个字段的值, 可以根据需要添加
getUser(user) {
// console.log(user);
return {
id: user.id,
username: user.get("username"),
email: user.get("email"),
}
},

// 将从服务器端取到的数据不能直接使用,转为对象格式的数据
toJson(item) {
return {
id: item.id, // leancloud Class 自带属性,对应 leanclud Class 中的 "ObjectId", 可以用".id"直接引用
title: item.get('title'),
// 因为pre标签缺失hljs这个class,加上就好了将拿到的markdown内容,
// 用marked转成html字符串后,replace替换<pre>标签为<pre class="hljs">
// content: marked(item.get('content')).replace(/<pre>/g, "<pre class='hljs'>"),
content: this.markdown(item.get('content')),
views: item.get('views'),
reply: item.get('reply'),
level: item.get('level'),
createdAt: item.createdAt, // leancloud Class 自带属性,创建时间,不能修改值
updatedAt: item.updatedAt, // leancloud Class 自带属性,最后修改时间,只能leancloud 自动修改
/**
* createdBy、lastEditor 指向另一个表的字段, 默认只能得到该字段在关联表中的id 值
* 要得到完整值, 查询时字段名要包含在 include 中,
* 取值时,要先判断是否为空,如果为空,取值会抛出异常,
*/
createdBy: item.get("createdBy")
? this.getUser(item.get("createdBy"))
: "",
lastEditor: item.get("lastEditor")
? this.getUser(item.get("lastEditor"))
: "",
}
},

// 将 markdown 语法写的内容转为 html 显示
markdown(val) {
return marked(val).replace(/<pre>/g, "<pre class='hljs'>");
},

},

filters: {
// 使用 moment.js 定义日期时间过滤器函数
datetimeFormat(val) {
let now = new Date();
// 计算两个时间相差多少天
let days = moment(now).diff(moment(val), 'days');
if (days <= 3) {
// 小于三天的返回格式:1小时前
return moment(val).fromNow();
}
// 三天以上的返回日期时间
return moment(val).format("lll");
},
},

}
</script>

<style>
.title {
font-size: 14px;
font-weight: bold;
}

.info {
font-size: 12px;
color: #c2c2c2;
}

.author {
font-size: 14px;
font-weight: bold;
color: darkcyan;
}

.comment-user {
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
height: 200px;
}
.comment-content {
font-size: 14px;
}
</style>