项目设计-文章系统发布文章完整前后端设计

文章目录

  • 文章系统完整前后端设计
    • 一、数据库表结构(最新版)
    • 二、实体类设计(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 &gt;= #{query.startTime}
            </if>
            <if test="query.endTime != null and query.endTime != ''">
                AND create_time &lt;= #{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 &gt;= #{query.startTime}
            </if>
            <if test="query.endTime != null and query.endTime != ''">
                AND create_time &lt;= #{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封装,可以直接使用。

相关推荐
lifewange1 小时前
数据库2表设计
数据库
Csvn1 小时前
Vue 3 Composition API 深度解析
前端·vue.js
怀后同学.2 小时前
SQL注入之堆叠注入和绕过WAF
数据库·sql
重生之小比特2 小时前
【MySQL 数据库】数据类型
数据库·mysql
轻刀快马2 小时前
穿透 MySQL 索引专栏 (二):【核心机制】为什么 SELECT * 是性能杀手?扒开“回表”与“联合索引”的底裤
数据库·mysql
程序员老邢2 小时前
《人生底稿・番外篇12》37 岁程序员的工位双生 —— 旧主机的 “开发 + 摸鱼” 效率分区
java·程序员日常·人生底稿番外·中年码农·工作效率分区
yexuhgu2 小时前
JavaScript中函数防抖Debounce的原理与闭包实现方案
jvm·数据库·python
阿kun要赚马内2 小时前
Python中的ORM——SQLAlchemy
数据库·oracle
m0_613856292 小时前
C#怎么判断进程是否在运行_C#如何管理系统进程【必备】
jvm·数据库·python