Spring boot开源项目之个人博客(15)—博客详情页面展示
Spring boot开源项目之个人博客(15)—博客详情页面展示
该博客页面被划分为两大模块:其一是用于展示博客详情信息的内容模块;其二是专门处理用户评论的功能模块。在此之前开发了一个全局搜索功能。
1. 全局搜索
导航栏内有一个专门设置的搜索框。其功能主要是通过输入字段来实现对标题及内容中涉及该字段的相关文章进行检索。进而将筛选出的相关文章按照 pagination 分布显示。
对页面进行简要处理;复制并粘贴来自之前博客列表中的div元素;避免了再次编写theamleaf来渲染这些内容;主要功能是通过指定搜索字段来检索符合特定条件的博客;从前端端口出发查看相关配置信息。
<form action="#" name="search" th:action="@{/search}" method="post">
<div class="ui icon input">
<input type="text" placeholder="search..." name="query" th:value="${query}">
<i onclick="document.search.submit()" class="search link icon"></i>
</div>
</form>
将搜索框包裹在form中,并以POST方式提交数据。接着绑定点击事件给图标元素。随后通过名称属性定位到表单字段。当用户点击表单时会自动触发提交动作。输入字段会向后台发送一个查询字符串用于后续处理需求。等到后端完成响应处理之后系统会跳转至返回页面并将获取到的查询结果渲染回搜索框供用户查看和使用
后台controller方法
@PostMapping("/search")
public String search(@PageableDefault(size = 6, sort = {"updateTime"}, direction = Sort.Direction.DESC) Pageable pageable,
Model model, @RequestParam String query){
model.addAttribute("page", blogService.listBlog("%" +query+ "%", pageable));
model.addAttribute("query", query);
return "search";
}
@RequestParam String query用来获取前端发送的搜索参数字符串,在controller中主要会将这些数据传递给前端。
service层
@Override
public Page<Blog> listBlog(String query, Pageable pageable) {
return blogRepository.findBlogByQuery(query, pageable);
}
将搜索条件及pageable对象传递至 DAO 层,在数据库层面进行数据查询。由于需执行分页操作,必须返回 Page<Blog> 类型的对象。这需特别注意,否则前端将无法获取所需数据。
dao层
@Query("select b from Blog b where b.title like ?1 or b.content like ?1")
Page<Blog> findBlogByQuery(String query, Pageable pageable);
基于...关键字构建自定义查询语句。其中...用于指定第一个查询参数。SQL中执行 LIKE 查询时使用的字段具有特定格式:"%"内容"%"(两端各附加一个百分号)。前端传递的数据类型为字符串,则需将其与前后缀拼接起来形成完整路径。该操作位于controller层面上完成。
至此, 该功能已实现全部功能. 当用户输入搜索条件后进行搜索操作时, 即可跳转至新的搜索结果页面, 博客列表将分页展示.
2. 博客内容处理
此部分与列表具有相似性,在前端操作时需要首先通过博客对象的ID获取对应的内容,并应用to标签实现对前端页面的渲染。具体操作中需要注意对博客内容进行处理。
博客中的文章数据采用带有Markdown语法标记的文本形式存储,并非简单的纯文本文件。显然,在未经处理的情况下直接将这些文本渲染到网页上会导致格式问题。因此需要将包含Markdown语法标记的文字转换为HTML格式以确保正确显示的同时不影响数据库中原有的原始结构信息和相关属性值等关键数据项。同时不想修改数据库中的原始数据因为文章编辑时仍需快速提取和查看原始内容此处仍需保持Markdown语法的形式以便后续的操作需求此外我们已安装了一个能够自动生成目录功能的插件表格部分希望采用基于Meaningful UI样式的设计方案对于文章中的超链接标签(如a)希望点击后能够跳转到新页面这时就需要一个灵活方便并且易于配置使用的Markdown转HTML转换工具在完成转换操作后还可以根据实际需求进行一些定制化设置比如调整字体大小或颜色优化布局结构等
首先,引入依赖
<!--markdown转html插件-->
<dependency>
<groupId>com.atlassian.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.10.0</version>
</dependency>
<dependency>
<groupId>com.atlassian.commonmark</groupId>
<artifactId>commonmark-ext-heading-anchor</artifactId>
<version>0.10.0</version>
</dependency>
<dependency>
<groupId>com.atlassian.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId>
<version>0.10.0</version>
</dependency>
第一个主要承担将Markdown格式转换为HTML代码的任务。为了实现表头和表格内容的自定义显示需求,在此之后又添加了另外两个模块。
controller层
@GetMapping("/blog/{id}")
public String blog(@PathVariable Long id, Model model){
model.addAttribute("blog", blogService.getAndInvertBlog(id));
return "blog";
}
通过给定的id值查找对应博客对象,并在服务层上对博客对象的内容执行处理操作后,在前端进行展示
service层
@Override
public Blog getAndInvertBlog(Long 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(MarkdownUtils.markdownToHtmlExtensions(content));
return b;
}
首先通过指定id获取相应的博客对象。此处将获取的对象的数据赋值给新建出来的博客实例。其原因在于直接对原有对象进行设置操作可能导致数据库内容的变化。随后开发了一个自定义工具类用于将Markdown文本转换为HTML格式,并增加了多种定制化功能。
public class MarkdownUtils {
/* *markdown格式转换成HTML格式
*/
public static String markdownToHtml(String markdown){
Parser parser = Parser.builder().build();
org.commonmark.node.Node docoment = parser.parse(markdown);
HtmlRenderer renderer = HtmlRenderer.builder().build();
return renderer.render(docoment);
}
/* *增加扩展[标题锚点,表格生成]
* Markdown转换成HTML
*/
public static String markdownToHtmlExtensions(String markdowm){
//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(markdowm);
HtmlRenderer renderer = HtmlRenderer.builder()
.extensions(headingAnchorExtensions)
.extensions(tableExtension)
.attributeProviderFactory(new AttributeProviderFactory() {
@Override
public AttributeProvider create(AttributeProviderContext attributeProviderContext) {
return new CustomAttributeProvider();
}
})
.build();
return renderer.render(document);
}
/* *处理标签的属性
*/
static class CustomAttributeProvider implements AttributeProvider{
@Override
public void setAttributes(Node node, String s, Map<String, String> map) {
//改变a标签的target属性为_blank
if (node instanceof Link){
map.put("target", "_blank");
}
if (node instanceof TableBlock){
map.put("class", "ui celled table");
}
}
}
}
3. 评论功能
评论功能划分为三个模块:首先进行基础展示;其次进行二级呈现;最后增加一个管理员评论。
- 评论列表的平铺展示
在评论发布后立即在此页面重新显示内容,并不致使其他页面布局发生变更,这正是 AJAX 技术的应用场景之一。为了实现这一功能需求,在 backend 端必须准备好处理 incoming Comment 对象,并相应地进行数据同步操作。
<div class="ui bottom attached segment">
<div id="comments-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 class="ui avatar image" src="https://picsum.photos/id/100/100/100" th:src="@{${comment.avatar}}" alt="">
</a>
<div class="content">
<a class="author">
<span th:text="${comment.nickname}">Matt</span>
<div th:if="${comment.adminComment}" class="ui teal basic left pointing mini label m-padded-mini">博主</div>
</a>
<div class="metadata">
<span class="date" th:text="${#calendars.format(comment.createTime, 'yyyy-MM-dd HH:dd')}">Today at 5:42PM</span>
</div>
<div class="text">
<span th:text="${comment.content}">How artistic!</span>
</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 class="ui avatar image" th:src="@{${reply.avatar}}" src="https://picsum.photos/id/100/100/100" alt="">
</a>
<div class="content">
<a class="author">
<span th:text="${reply.nickname}">Jenny Hess</span>
<div th:if="${reply.adminComment}" class="ui teal basic left pointing mini label m-padded-mini">博主</div>
<span th:text="|@ ${reply.parentComment.nickname}|" class="m-teal">@小白</span>
</a>
<div class="metadata">
<span class="date" th:text="${#calendars.format(reply.createTime, 'yyyy-MM-dd HH:dd')}">Just now</span>
</div>
<div class="text">
<span th:text="${reply.content}">Elliot you are always so right :)</span>
</div>
<div class="actions">
<a class="reply" th:attr="data-commentid=${reply.id},data-commentnickname=${reply.nickname}" onclick="reply(this)">回复</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="ui reply form">
<input type="hidden" name="blog.id" th:value="${blog.id}">
<input type="hidden" name="parentComment.id" value="-1">
<div class="required field">
<textarea name="content" placeholder="请评论...."></textarea>
</div>
<div class="fields">
<div class="required field m-mobile-wide">
<div class="ui left icon input">
<i class="user icon"></i>
<input type="text" placeholder="姓名" name="nickname" class="fluid" th:value="${session.user}!=null ? ${session.user.nickname}">
</div>
</div>
<div class="required field m-mobile-wide">
<div class="ui left icon input">
<i class="mail icon"></i>
<input type="email" name="email" placeholder="邮箱" th:value="${session.user}!=null ? ${session.user.email}">
</div>
</div>
<div class="field m-mobile-wide">
<button id="comment-btn" type="button" class="ui icon fluid teal button">
<i class="edit icon"></i>
发布
</button>
</div>
</div>
<div class="ui error message"></div>
</div>
</div>
在页面外部结构中首先声明了一个带有标识符comments-container的div元素以及一个基于th:fragment属性定位的commentList表单元素。这些元素用于接收AJAX提交的数据并实现本地页面刷新功能。在表单中添加了两个隐藏字段元素分别命名为'blog.id'和'parentComment.id'。这两个字段用于记录当前评论所在的博客ID以及其上级评论的ID(其中上级评论ID预设为-1表示该评论无上级)。当用户选择回复时,在编辑框内会自动添加前缀‘@用户名’以标识相关对话,并将其父级ID设置为其被回复评论所属的ID。从而实现了当前对象所需的一些属性配置
[th]:attribute="data-comment_id=${comment.id},data-commentname=${comment.nickname}"
然后定义其点击事件
function reply(obj) {
var commentId = $(obj).data('commentid');
var commentNickname = $(obj).data('commentnickname');
$("[name='parentComment.id']").val(commentId);
$("[name='content']").attr("placeholder", "@" + commentNickname).focus();
}
定义ajax方式提交方法
function postdata(){
$("#comments-container").load(/*[[@{/comments}]]*/"/comments", {
"parentComment.id": $("[name='parentComment.id']").val(),
"blog.id": $("[name='blog.id']").val(),
"content": $("[name='content']").val(),
"nickname": $("[name='nickname']").val(),
"email": $("[name='email']").val()
},function (responseTxt, statusTxt, xhr) {
cleardata();
});
}
function cleardata() {
$("[name='parentComment.id']").val(-1);
$("[name='content']").val("");
$("[name='content']").attr("placeholder", "请评论....").focus();
}
通过调用load方法将指定数据传递至服务器端的同时支持自定义回传处理逻辑,在此过程中实现了对评论数据清除功能的实现。其中代码块...部分...具体说明了该功能的作用机制:重置父级ID字段值并清除当前评论内容后恢复占位信息设置以完成操作流程。需要注意的是,在采用theamleaf框架下的动态ID获取机制时...应在外部脚本中添加样式表声明以实现此效果。
定义发布按钮事件
$("#comment-btn").click(function () {
var boo = $('.ui.form').form('validate form');
if (boo){
console.log("校验成功");
postdata();
}else {
console.log("校验失败");
}
});
使用UI框架选择并调用validateForm方法获取所有形式元素,并将其结果存储于一个布尔类型变量boo中。其中boo是一个布尔类型变量其值由form表单验证结果决定当form表单验证通过时则会触发Ajax提交流程具体实现细节已略去不计这部分内容之前已经介绍过
controller层
@GetMapping("/comments/{blogId}")
public String comments(@PathVariable Long blogId, Model model){
model.addAttribute("comments" , commentService.listCommentByBlogId(blogId));
return "blog :: commentList";
}
@PostMapping("/comments")
public String postComment(Comment comment, HttpSession session){
Long blogId = comment.getBlog().getId();
comment.setBlog(blogService.getBlog(blogId));
User user = (User) session.getAttribute("user");
if (user != null){
comment.setAvatar(user.getAvatar());
comment.setAdminComment(true);
}else {
comment.setAvatar(avatar);
comment.setAdminComment(false);
}
commentService.saveComment(comment);
return "redirect:/comments/" + comment.getBlog().getId();
}
此方案通过博客ID将评论对象发送至前端并触发本地页面刷新。此方案采用AJAX提交数据后自动处理相关数据。其中,在AJAX返回的数据基础上需额外关注的属性包括头像字段、博客对象信息以及创建时间字段等。对于头像字段的管理,则采用了如下策略:首先在配置文件中进行声明,并于类初始化阶段赋值。
application.yaml中的声明
comment.avatar: /images/avatar.jpg
在controller中拿值
@Value("${comment.avatar}")
String avatar;
由于在Comment对象中包含了Blog对象属性。前端通过隐藏域将name属性设置为blog.id。这样后端处理后,在Comment对象中会自动赋予Blog对象属性对应的id值。该逻辑实现的是管理员评论功能中的第三部分。根据session信息判断管理员是否登录状态,并据此在Comment对象上赋予不同的默认值。这部分功能较为简单且不具备特殊价值,则不进行详细记录;如有需要则稍作记录即可完成相关操作
最终实现了通过服务方法来实现数据持久化的功能。在重定向到指定URL时会调用comments(@PathVariable Long blogId, Model model)这一特定方法。值得注意的是,在该模式中将接收与推送操作进行了分离处理的方案具有较高的参考价值
service层
@Override
public List<Comment> listCommentByBlogId(Long blogId) {
Sort sort = Sort.by(Sort.Direction.ASC,"createTime");
List<Comment> comments = commentRepository.findByBlogIdAndParentCommentNull(blogId, sort);
return eachComment(comments);
}
@Override
public Comment saveComment(Comment comment) {
Long parentCommentId = comment.getParentComment().getId();
if (parentCommentId != -1){
comment.setParentComment(comment.getParentComment());
}else {
comment.setParentComment(null);
}
comment.setCreateTime(new Date());
return commentRepository.save(comment);
}
第二种方案旨在实现对Comment对象的持久化存储。第一方案则通过指定Blog ID获取一个包含Comment的对象列表。这些列表按照创建时间进行排序。网页渲染完成后,所有的回复均以平面形式显示。为了提高用户体验的一致性与可读性,在当前版本中将二级与一级 comment 保留在同一层次位置。我们的目标是在每个顶级 comment 中嵌入所有子级 comment,并将其视为独立的一级内容
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3RbRRw1m-1598502974506)(D:\note\target\Springboot博客开源项目笔记\一些截图\后端\评论前端.png)]
像这样,所有的二级、三级及以后的都平铺在二级评论部分。
- Comment对象数据结构转化实现二级评论展示功能。
这部分数据结构需要做这样的转化
外链图片无法显示,网站可能存在防盗链设置,可采取以下措施将原始文件另存为并重新上传(img-gCIvLGVt-1598502974508)(D:\note\target\Springboot博客开源项目笔记\一些截图\后端\数据结构.png)
数据库中的数据架构采用了类似左图所示的树形结构。因此,我们的目标是将其转化为如左图所示的二级架构。从代码实现的角度来看,在集合中放置所有的父级节点和所有的子级节点,并确保它们之间的一一对应关系得以保持。在Comment对象中有专门的属性存储回复相关的Comment对象private List<Comment> replyComments = new ArrayList<>();数据结构的转换工作则由服务层负责处理。具体来说,在服务层中实现了三个核心方法:
/* *循环每个顶级的节点
*/
private List<Comment> eachComment(List<Comment> comments){
List<Comment> commentView = new ArrayList<>();
for (Comment comment : comments){
Comment c = new Comment();
BeanUtils.copyProperties(comment,c);
commentView.add(c);
}
combileChildren(commentView);
return commentView;
}
private void combileChildren(List<Comment> comments){
for (Comment comment : comments){
List<Comment> replys1 = comment.getReplyComments();
for (Comment reply1 : replys1){
recursively(reply1);
}
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);
}
}
}
}
采用复制父级评论的方式以防止原有对象的更改影响数据库数据。通过合并各个父级下的所有子级来实现某种功能。第三种方法最为关键,采用迭代遍历所有子级,将它们加入到临时容器tempReplys中,并将这一结果设置为ReplyComments属性的值。随后将数据传递至前端端点,随后完成渲染操作即可实现预期效果。管理员评论则不予记录,在操作质量上稍显不足。
