__
1. 需求分析
完整的博客详情页如上述所示,页面中包含的部分主要分为三大块:
-
博客详情:详情信息中包含如下的内容:
- 作者
- 更新时间
- 浏览次数
- 标签
- 正文
- 赞赏
- 转载声明
-
评论:评论部分首先设定为两级的层次结构,原始评论为父级,它下面相应的评论都归为子级评论。此外,评论还需要区分是博主还是访客发表,如果是博主需要显示博主标签和头像;如果是访客,显示的是同一的访客头像
-
侧边栏:侧边栏包含三部分:
- 目录:根据正文的Markdown语法的标题级别自动生成目录
- 留言:跳转按钮,直接跳转到评论区
- 二维码生成:根据博客生成二维码,访客可扫描二维码直接在移动端查看博客内容
侧边栏部分的实现主要是插件的应用,不需要后端进行处理
2. 前端处理
博客详情页的前段时间设计如下,根据需求分析可知,主要分为三大部分:
<div id="waypoint" class="m-container-small m-padded-tb-big animated fadeIn">
<div class="ui container">
<!--博客正文的头部,显示作者、更新时间、浏览次数-->
<div class="ui top attached segment">
<div class="ui horizontal link list">
<div class="item">
<img src="https://unsplash.it/100/100?image=1005" th:src="@{${blog.user.avatar}}" alt="" class="ui avatar image">
<div class="content"><a href="#" class="header" th:text="${blog.user.nickname}">Forlogen</a></div>
</div>
<div class="item">
<i class="calendar icon"></i> <span th:text="${#dates.format(blog.updateTime,'yyyy-MM-dd')}">2020-06-11</span>
</div>
<div class="item">
<i class="eye icon"></i> <span th:text="${blog.views}">2</span>
</div>
</div>
</div>
<!--博客正文部分-->
<div class="ui attached segment">
<!--图片区域-->
<img src="https://unsplash.it/800/450?image=1005" th:src="@{${blog.firstPicture}}" alt="" class="ui fluid rounded image">
</div>
<div class="ui attached padded segment">
<!--标题和flag-->
<div class="ui right aligned basic segment">
<div class="ui orange basic label" th:text="${blog.flag}">原创</div>
</div>
<h2 class="ui center aligned header" th:text="${blog.title}">关于刻意练习的清单</h2>
<br>
<!--正文部分-->
<div id="content" class="typo typo-selection js-toc-content m-padded-lr-responsive m-padded-tb-large" th:utext="${blog.content}">
</div>
<!--标签-->
<div class="m-padded-lr-responsive">
<div class="ui basic teal left pointing label" th:each="tag : ${blog.tags}" th:text="${tag.name}">方法论</div>
</div>
<!--赞赏-->
<div th:if="${blog.appreciation}">
<div class="ui center aligned basic segment">
<button id="payButton" class="ui orange basic circular button">赞赏</button>
</div>
<div class="ui payQR flowing popup transition hidden">
<div class="ui orange basic label">
<div class="ui images" style="font-size: inherit !important;">
<div class="image">
<img src="../static/images/wechat.jpg" th:src="@{/images/wechat.jpg}" alt="" class="ui rounded bordered image" style="width: 120px">
<div>支付宝</div>
</div>
<div class="image">
<img src="../static/images/wechat.jpg" th:src="@{/images/wechat.jpg}" alt="" class="ui rounded bordered image" style="width: 120px">
<div>微信</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="ui attached positive message" th:if="${blog.shareStatement}">
<!--转载声明-->
<div class="ui middle aligned grid">
<div class="eleven wide column">
<ui class="list">
<li>作者:<span th:text="${blog.user.nickname}">Forlogen</span><a href="#" th:href="@{/about}" target="_blank">(联系作者)</a></li>
<li>发表时间:<span th:text="${#dates.format(blog.updateTime,'yyyy-MM-dd HH:mm')}">2020-06-30 09:08</span></li>
<li>版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)</li>
</ui>
</div>
<div class="five wide column">
<img src="../static/images/wechat.jpg" th:src="@{/images/wechat.jpg}" alt="" class="ui right floated rounded bordered image" style="width: 110px">
</div>
</div>
</div>
<!--留言区域列表-->
<div class="ui bottom attached segment" th:if="${blog.commentable}">
<div id="comment-container" class="ui teal segment">
<div th:fragment="commentList">
<div class="ui threaded comments" style="max-width: 100%;">
<h3 class="ui dividing header">评论</h3>
<div class="comment" th:each="comment : ${comments}">
<a class="avatar">
<img src="https://unsplash.it/100/100?image=1005" th:src="@{${comment.avatar}}">
</a>
<div class="content">
<a class="author" >
<span th:text="${comment.nickname}">Matt</span>
<div class="ui mini basic teal left pointing label m-padded-mini" th:if="${comment.adminComment}">博主</div>
</a>
<div class="metadata">
<span class="date" th:text="${#dates.format(comment.createTime,'yyyy-MM-dd HH:mm')}">Today at 5:42PM</span>
</div>
<div class="text" th:text="${comment.content}">
How artistic!
</div>
<div class="actions">
<a class="reply" data-commentid="1" data-commentnickname="Matt" th:attr="data-commentid=${comment.id},data-commentnickname=${comment.nickname}" onclick="reply(this)">回复</a>
</div>
</div>
<div class="comments" th:if="${#arrays.length(comment.replyComments)}>0">
<div class="comment" th:each="reply : ${comment.replyComments}">
<a class="avatar">
<img src="https://unsplash.it/100/100?image=1005" th:src="@{${comment.avatar}}">
</a>
<div class="content">
<a class="author" >
<span th:text="${reply.nickname}">小红</span>
<div class="ui mini basic teal left pointing label m-padded-mini" th:if="${reply.adminComment}">博主</div>
<span th:text="|@ ${reply.parentComment.nickname}|" class="m-teal">@ 小白</span>
</a>
<div class="metadata">
<span class="date" th:text="${#dates.format(reply.createTime,'yyyy-MM-dd HH:mm')}">Today at 5:42PM</span>
</div>
<div class="text" th:text="${reply.content}">
How artistic!
</div>
<div class="actions">
<a class="reply" data-commentid="1" data-commentnickname="Matt" th:attr="data-commentid=${reply.id},data-commentnickname=${reply.nickname}" onclick="reply(this)">回复</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!--评论输入区-->
<div id="comment-form" class="ui form">
<input type="hidden" name="blog.id" th:value="${blog.id}">
<input type="hidden" name="parentComment.id" value="-1">
<div class="field">
<textarea name="content" placeholder="请输入评论信息..."></textarea>
</div>
<div class="fields">
<div class="field m-mobile-wide m-margin-bottom-small">
<div class="ui left icon input">
<i class="user icon"></i>
<input type="text" name="nickname" placeholder="姓名" th:value="${session.user}!=null ? ${session.user.nickname}">
</div>
</div>
<div class="field m-mobile-wide m-margin-bottom-small">
<div class="ui left icon input">
<i class="mail icon"></i>
<input type="text" name="email" placeholder="邮箱" th:value="${session.user}!=null ? ${session.user.email}">
</div>
</div>
<div class="field m-margin-bottom-small m-mobile-wide">
<button id="commentpost-btn" type="button" class="ui teal button m-mobile-wide"><i class="edit icon"></i>发布</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!--侧边栏-->
<div id="toolbar" class="m-padded m-fixed m-right-bottom" style="display: none">
<div class="ui vertical icon buttons ">
<button type="button" class="ui toc teal button" >目录</button>
<a href="#comment-container" class="ui teal button" >留言</a>
<button class="ui wechat icon button"><i class="weixin icon"></i></button>
<div id="toTop-button" class="ui icon button" ><i class="chevron up icon"></i></div>
</div>
</div>
<div class="ui toc-container flowing popup transition hidden" style="width: 250px!important;">
<ol class="js-toc">
</ol>
</div>
<div id="qrcode" class="ui wechat-qr flowing popup transition hidden "style="width: 130px !important;">
<!--<img src="./static/images/wechat.jpg" alt="" class="ui rounded image" style="width: 120px !important;">-->
</div>
其中赞赏、转载声明和评论是否启用,取决于博客发布时的选择,具体体现在Blog对象的相应字段的值。如果在博客发布时启用,那么在详情页就像现实对应的部分;如果不启用,那么就不会显示。
从前面页面的设计来看,后端需向前端传递的数据主要是两个部分:
- 博客对象Blog,前端需从中拿到各种字段的值使用
- 评论对象Comment,前端拿到评论进行显示
此外,如果此时博主登录管理后台成功,还需要从session中获取到博主相关的信息。
3. 后端处理
3.1 博客详情
首先我们来看对于博客内容部分的处理,表现层相应的处理方法为:
@Controller
public class IndexController {
@Autowired
private BlogService blogService;
@GetMapping("/blog/{id}")
public String blog(@PathVariable Long id, Model model) {
model.addAttribute("blog", blogService.getAndConvert(id));
return "blog";
}
}
后端根据博客的id来查询相应的Blog对象,调用的是getAndConvert()
,业务层的实现为:
public interface BlogService {
// 博客内容格式转换 MakeDown -> HTML
Blog getAndConvert(Long id);
}
由于数据库保存的博客内容为Markdown形式,如果想要在浏览器中正确的显示,就需要先将其转换为对应的HTML格式。接口实现类中方法的实现为:
@Service
public class BlogServiceImpl implements BlogService {
@Autowired
private BlogRepository blogRepository;
@Override
public Blog getAndConvert(Long id) {
// 首先根据id查询对应的博客是否存在
Blog blog = blogRepository.getOne(id);
if (blog == null) {
throw new NotFoundException("该博客不存在");
}
// 为了不改变博客数据库中保存的格式,首先获取博客的副本
Blog b = new Blog();
BeanUtils.copyProperties(blog,b);
// 获取博客正文
String content = b.getContent();
// 调用工具类中的方法实现格式转换
b.setContent(MakeDownUtils.markdownToHtmlExtensions(content));
// 更新浏览次数,打开一次算一次浏览
blogRepository.updateViews(id);
return b;
}
updateViews方法需要在持久层中自行定义,对应的实现为:
public interface BlogRepository extends JpaRepository<Blog, Long>, JpaSpecificationExecutor<Blog> {
// 更新阅读次数,每次点击算一次
@Transactional
@Modifying
@Query("update Blog b set b.views = b.views+1 where b.id = ?1")
int updateViews(Long id);
}
另外,工具类的实现如下:
public class MakeDownUtils {
/**
* markdown格式转换成HTML格式
* @param markdown
* @return
*/
public static String markdownToHtml(String markdown) {
Parser parser = Parser.builder().build();
Node document = parser.parse(markdown);
HtmlRenderer renderer = HtmlRenderer.builder().build();
return renderer.render(document);
}
/**
* 增加扩展[标题锚点,表格生成]
* Markdown转换成HTML
* @param markdown
* @return
*/
public static String markdownToHtmlExtensions(String markdown) {
//h标题生成id
Set<Extension> headingAnchorExtensions = Collections.singleton(HeadingAnchorExtension.create());
//转换table的HTML
List<Extension> tableExtension = Arrays.asList(TablesExtension.create());
Parser parser = Parser.builder()
.extensions(tableExtension)
.build();
Node document = parser.parse(markdown);
HtmlRenderer renderer = HtmlRenderer.builder()
.extensions(headingAnchorExtensions)
.extensions(tableExtension)
.attributeProviderFactory(new AttributeProviderFactory() {
public AttributeProvider create(AttributeProviderContext context) {
return new CustomAttributeProvider();
}
})
.build();
return renderer.render(document);
}
/**
* 处理标签的属性
*/
static class CustomAttributeProvider implements AttributeProvider {
@Override
public void setAttributes(Node node, String tagName, Map<String, String> attributes) {
//改变a标签的target属性为_blank
if (node instanceof Link) {
attributes.put("target", "_blank");
}
if (node instanceof TableBlock) {
attributes.put("class", "ui celled table");
}
}
}
}
这样经过后端处理就可以向前端传递处理后的结果,即给定id对应的Blog对象。注意,此时的Blog对象的content字段的内容已经是HTML格式。
3.2 评论
此外,后端还需要根据博客id字段的属性值来找到对应的评论信息,表现层对应的处理方法为:
@Controller
public class CommentController {
@Autowired
private CommentService commentService;
@Autowired
private BlogService blogService;
@Value("${comment.avatar}")
private String avatar;
@GetMapping("/comments/{blogId}")
public String comments(@PathVariable Long blogId, Model model) {
model.addAttribute("comments", commentService.listCommentByBlogId(blogId));
return "blog :: commentList";
}
}
业务层中listCommentByBlogId方法的实现为:
public interface CommentService {
List<Comment> listCommentByBlogId(Long blogId);
}
@Service
public class CommentServiceImpl implements CommentService {
@Autowired
private CommentRepository commentRepository;
@Override
public List<Comment> listCommentByBlogId(Long blogId) {
Sort sort = Sort.by("createTime");
// 首先根据博客id和指定的排序方式从数据库中找到对应的评论
List<Comment> comments = commentRepository.findByBlogIdAndParentCommentNull(blogId, sort);
// 最后分级显示
return eachComment(comments);
}
}
持久层的方法实现为:
public interface CommentRepository extends JpaRepository<Comment,Long> {
List<Comment> findByBlogIdAndParentCommentNull(Long blogId, Sort sort);
}
查询需要依赖两个字段的信息:Comment对象的parentComment的内容和Blog对象的id。其中parentComment和Comment中的replyComments存在多对一关系,从而后续可以实现分级显示。
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Entity
@Table(name = "t_comment")
public class Comment {
...
@OneToMany(mappedBy = "parentComment")
private List<Comment> replyComments = new ArrayList<>();
@ManyToOne
private Comment parentComment;
}
经过上述的过程获得到评论列表后,需要进一步的处理便于分级显示,对应的方法为:
//存放迭代找出的所有子代的集合
private List<Comment> tempReplys = new ArrayList<>();
private List<Comment> eachComment(List<Comment> comments) {
List<Comment> commentsView = new ArrayList<>();
for (Comment comment : comments) {
Comment c = new Comment();
BeanUtils.copyProperties(comment,c);
commentsView.add(c);
}
//合并评论的各层子代到第一级子代集合中
combineChildren(commentsView);
return commentsView;
}
private void combineChildren(List<Comment> comments) {
for (Comment comment : comments) {
List<Comment> replys1 = comment.getReplyComments();
for(Comment reply1 : replys1) {
//循环迭代,找出子代,存放在tempReplys中
recursively(reply1);
}
//修改顶级节点的reply集合为迭代处理后的集合
comment.setReplyComments(tempReplys);
//清除临时存放区
tempReplys = new ArrayList<>();
}
}
private void recursively(Comment comment) {
tempReplys.add(comment);//顶节点添加到临时存放集合
if (comment.getReplyComments().size()>0) {
List<Comment> replys = comment.getReplyComments();
for (Comment reply : replys) {
tempReplys.add(reply);
if (reply.getReplyComments().size()>0) {
recursively(reply);
}
}
}
}
经过处理后,将结果传递给前端进行渲染即可。
最后需要进行处理的就是评论信息的提交部分,评论信息包含评论内容、姓名和邮箱三部分。前端信息传递到后端需要JavaScript进行相应的处理,此外还有表单的验证等,这里不重点关注前端的逻辑。当后端获取到评论信息后,就需要将其保存到数据库中,表现层对应的处理方法为:
@Controller
public class CommentController {
@Autowired
private CommentService commentService;
@Autowired
private BlogService blogService;
// 此时为POST请求,评论信息包装为Comment对象
// 评论人信息根据session中保存的user获取
@PostMapping("/comments")
public String post(Comment comment, HttpSession session) {
// 首先获取到对应博客的id
Long blogId = comment.getBlog().getId();
// 根据博客id获取到对应的博客,设置Comment对象相应字段的值
comment.setBlog(blogService.getBlog(blogId));
// 判断评论的是博主还是访客
User user = (User) session.getAttribute("user");
if (user != null) {
// 如果是博主,除了设置头像外,还需要设置adminComment字段为true
comment.setAvatar(user.getAvatar());
comment.setAdminComment(true);
} else {
// 否则只需要选择访客头像
// adminComment默认为false,表示为访客评论
comment.setAvatar(avatar);
}
// 最后保存评论,重定向到评论显示部分
commentService.saveComment(comment);
return "redirect:/comments/" + blogId;
}
}
其中saveComment方法在持久层上仍然是Jpa帮我们自动实现的,业务层只需要调用使用即可。
public interface CommentService {
Comment saveComment(Comment comment);
}
方法的实现如下:
@Transactional
@Override
public Comment saveComment(Comment comment) {
// 首先看是不是一级评论
Long parentCommentId = comment.getParentComment().getId();
if (parentCommentId != -1) {
// 如果不是,还需要找到它的父级评论,然后设置parentComment字段的值
comment.setParentComment(commentRepository.getOne(parentCommentId));
} else {
// 否则无需设置
comment.setParentComment(null);
}
// 最后设置评论时间,保存评论
comment.setCreateTime(new Date());
return commentRepository.save(comment);
}
4. footer
所谓footer就是每个页面的最底部显示的信息,它是被所有的页面共用的部分,如下所示:
其中包含五部分信息:
- 博主的联系方式:包括CSDN、GitHub和邮箱
- 友链
- 博客的一些统计信息:文章总数、阅读量、评论数
- 博客简介
- 版权申明
它对应的前端设计如下:
<!--底部-->
<footer th:fragment="footer" class="ui inverted vertical segment m-padded-tb-massive">
<div class="ui center aligned container">
<div class="ui inverted divided stackable grid">
<!--博主联系方式-->
<div class="three wide column">
<div class="ui inverted link list">
<a href="https://github.com/" class="ui github circular icon button" data-content="点击跳转GitHub"><i class="github icon"></i></a>
<a href="https://forlogen.blog.csdn.net/" class="ui csdn circular icon button" data-content="点击跳转CSDN" ><i class="copyright outline icon"></i></a>
<a href="#" class="ui email circular icon button" data-content="18dyliang@stu.edu.cn"><i class="envelope icon"></i></a>
</div>
</div>
<!--友情链接-->
<div class="three wide column">
<h4 class="ui inverted header m-text-thin m-text-spaced " >友情链接</h4>
<div class="ui inverted link list">
<a href="https://www.csdn.net/" class="item m-text-thin">CSDN</a>
<a href="https://github.com/" class="item m-text-thin">GitHUb</a>
<a href="https://www.cnblogs.com/" class="item m-text-thin">博客园</a>
<a href="https://www.zhihu.com/" class="item m-text-thin">知乎</a>
</div>
</div>
<!--博客统计信息-->
<div class="three wide column">
<h4 class="ui inverted header m-text-thin m-text-spaced " >博客信息</h4>
<div id="statistic-container">
<div th:fragment="statisticList" class="ui inverted center aligned link list" style="align-content: center;margin-top: 10px">
<div class="center aligned m-text-thin">
文章总数: <h2 class="ui orange header m-inline-block m-margin-top-null" th:text="${blogCount}" style="font-size:medium;"> 14 </h2> 篇
</div>
<div class="m-text-thin">
访问总数: <h2 class="ui orange header m-inline-block m-margin-top-null" th:text="${blogView}" style="font-size:medium;"> 14 </h2> 次
</div>
<div class="m-text-thin">
评论总数: <h2 class="ui orange header m-inline-block m-margin-top-null" th:text="${blogComment}" style="font-size:medium;"> 14 </h2> 条
</div>
</div>
</div>
</div>
<!--博客简介-->
<div class="seven wide column">
<h4 class="ui inverted header m-text-thin m-text-spaced ">Blog</h4>
<p class="m-text-thin m-text-spaced m-opacity-mini">这是我的个人博客、会分享关于编程、写作、思考相关的任何内容,希望可以给来到这儿的人有所帮助...</p>
</div>
</div>
<div class="ui center aligned inverted section divider"></div>
<p class="m-text-thin m-text-spaced m-opacity-tiny">Copyright © 2016 - 2017 Designed by Forlogen</p>
<p class="m-text-thin m-text-spaced m-opacity-tiny">本站托管于
<a href="https://cn.aliyun.com/" style="color: #aac6e3">阿里云</a>
</p>
</div>
</footer>
那么只有博客统计信息部分需要后端查询,然后返回相应的值。表现层对应的处理方法是:
@Controller
public class FooterController {
@Autowired
private BlogService blogService;
@Autowired
private CommentService commentService;
@GetMapping("/footer/statistic")
public String statistic(Model model){
model.addAttribute("blogCount", blogService.countBlog());
model.addAttribute("blogView", blogService.numberOfViews());
model.addAttribute("blogComment", commentService.numberOfComment());
return "_fragments :: statisticList";
}
}
对应在持久层就是一些自定义查询的实现,如下所示:
public interface BlogRepository extends JpaRepository<Blog, Long>, JpaSpecificationExecutor<Blog> {
@Query("select sum(b.views) from Blog b")
Long numberOfviews();
}
@Service
public class BlogServiceImpl implements BlogService {
@Autowired
private BlogRepository blogRepository;
@Override
public Long countBlog() {
return blogRepository.count();
}
}
@Service
public class CommentServiceImpl implements CommentService {
@Autowired
private CommentRepository commentRepository;
@Override
public Long numberOfComment() {
return commentRepository.count();
}
}
5. 总结
完结撒花啦~