首页 > 基础资料 博客日记

JaVers 版本历史功能完整实现指南

2026-05-29 09:30:06基础资料围观9

文章JaVers 版本历史功能完整实现指南分享给大家,欢迎收藏极客资料网,专注分享技术知识

JaVers 版本历史功能完整实现指南

本文档基于 FBI 系统的 JaVers 实现,提供从零开始在 Spring Boot + Vue 3 项目中集成实体版本历史功能的完整指导。涵盖依赖配置、事件驱动架构、Entity/DTO 注解、API 设计、前端 HistoryDialog 组件等全部环节。


目录

  1. 技术选型与依赖
  2. JaVers 配置
  3. 核心基础设施(事件驱动架构)
  4. 实体与 DTO 的 JaVers 注解规范
  5. 业务层集成(如何发布事件触发快照)
  6. 后端 API 设计(查询、详情、对比)
  7. 已有数据初始化快照
  8. 前端 HistoryDialog 组件
  9. 前端 API 层
  10. 页面集成示例
  11. 完整实施步骤清单

1. 技术选型与依赖

后端(Java)

JaVers 版本7.9.0

Maven 依赖(在父 POM 的 <dependencyManagement> 中声明版本):

<properties>
    <javers.version>7.9.0</javers.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.javers</groupId>
            <artifactId>javers-spring-boot-starter-sql</artifactId>
            <version>${javers.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

在 common 模块中引入(所有业务模块依赖 common):

<dependency>
    <groupId>org.javers</groupId>
    <artifactId>javers-spring-boot-starter-sql</artifactId>
</dependency>

说明:使用 javers-spring-boot-starter-sql 而非 javers-spring-boot-starter-mongo。JaVers 的快照数据存储在应用同一 MySQL 数据库中,无需额外数据库。

前端(Vue 3)

无需额外依赖,使用 Element Plus 的 el-tableel-scrollbarel-checkboxel-empty 组件即可。


2. JaVers 配置

application.yml

javers:
  spring-data:
    enabled: false    # 不使用 @JaversSpringDataAuditable 自动审计
  sql:
    enabled: true
    # 显式指定使用应用的 EntityManagerFactory,确保 JaVers 与应用共享事务
    entity-manager-bean-name: entityManagerFactory

关键说明

  • spring-data.enabled: false:我们不使用 @JaversSpringDataAuditable 注解。这种自动审计方式会导致每次 Spring Data JPA 保存时都自动提交快照,粒度太粗且无法使用 DTO 包装。
  • sql.enabled: true:使用 SQL 存储(MySQL)。
  • entity-manager-bean-name:必须指定,确保 JaVers 的快照提交在应用的同一事务中。如果 JaVers 提交失败,整个业务操作回滚。

JaVers 自动创建的表

启动应用后,JaVers 会在数据库中自动创建以下表(通过 JPA ddl-auto=update 或 JaVers 内置的 Liquibase 脚本):

表名 用途
javers_commit 每次快照提交的元数据(作者、时间、提交ID)
javers_snapshot 实体在每个版本号下的完整状态(JSON 序列化)
javers_global_id 全局对象标识(类型名 + 实体ID 的映射)

3. 核心基础设施(事件驱动架构)

3.1 设计理念

不使用 JaVers 自带的 @JaversSpringDataAuditable,而是通过自定义 Spring 事件机制手动触发快照提交。这样做的优势:

  1. 精确控制提交时机:只在业务真正完成时提交,不在中间状态提交
  2. 支持 DTO 包装:可以提交自定义的 SnapshotDTO(包含关联数据),而非原始 Entity
  3. 统一事件入口:所有实体的变更通过同一事件通道,方便后续扩展(如通知、日志等)
  4. 同步事务保证:事件监听器是同步的,快照提交与业务操作在同一事务中

3.2 EntityChangeEvent —— 事件类

package your.project.common.event;

import lombok.Getter;
import org.springframework.context.ApplicationEvent;

@Getter
public class EntityChangeEvent extends ApplicationEvent {

    public enum ChangeType {
        CREATED,    // 新增
        UPDATED,    // 更新
        DELETED     // 删除
    }

    private final ChangeType changeType;
    private final Object entity;        // 要提交给 JaVers 的对象(可以是 Entity 或 DTO)
    private final String author;        // 操作人标识

    public EntityChangeEvent(Object source, ChangeType changeType, Object entity, String author) {
        super(source);
        this.changeType = changeType;
        this.entity = entity;
        this.author = author;
    }

    /** 通过反射获取实体 ID */
    public Long getEntityId() {
        if (entity != null) {
            try {
                var method = entity.getClass().getMethod("getId");
                return (Long) method.invoke(entity);
            } catch (Exception e) {
                return null;
            }
        }
        return null;
    }

    /** 获取实体类型名称 */
    public String getEntityType() {
        return entity != null ? entity.getClass().getSimpleName() : null;
    }
}

3.3 EntityChangeEventPublisher —— 事件发布器

package your.project.common.event;

import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class EntityChangeEventPublisher {

    private static final String DEFAULT_AUTHOR = "system";
    private final ApplicationEventPublisher publisher;

    /** 获取当前登录用户标识。
     *  你需要替换为你们项目中获取当前用户的方法 */
    private String getCurrentAuthor() {
        // 示例:从 SecurityContext 或自定义 ThreadLocal 获取
        // return SecurityContextHolder.getContext().getAuthentication().getName();
        return DEFAULT_AUTHOR;
    }

    /** 发布新增事件 */
    public <T> void publishCreated(T entity) {
        publishCreated(entity, getCurrentAuthor());
    }

    public <T> void publishCreated(T entity, String author) {
        publisher.publishEvent(new EntityChangeEvent(
                this, EntityChangeEvent.ChangeType.CREATED, entity, author));
    }

    /** 发布更新事件 */
    public <T> void publishUpdated(T entity) {
        publishUpdated(entity, getCurrentAuthor());
    }

    public <T> void publishUpdated(T entity, String author) {
        publisher.publishEvent(new EntityChangeEvent(
                this, EntityChangeEvent.ChangeType.UPDATED, entity, author));
    }

    /** 发布删除事件 */
    public <T> void publishDeleted(T entity) {
        publishDeleted(entity, getCurrentAuthor());
    }

    public <T> void publishDeleted(T entity, String author) {
        publisher.publishEvent(new EntityChangeEvent(
                this, EntityChangeEvent.ChangeType.DELETED, entity, author));
    }
}

3.4 EntityChangeEventListener —— 事件监听器(核心)

package your.project.common.event;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.javers.core.Javers;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
@Slf4j
public class EntityChangeEventListener {

    private final Javers javers;

    /**
     * 同步监听实体变更事件,将实体状态提交到 JaVers。
     * 同步执行保证快照提交与业务操作在同一数据库事务中。
     */
    @EventListener
    public void handleEvent(EntityChangeEvent event) {
        try {
            switch (event.getChangeType()) {
                case CREATED, UPDATED, DELETED:
                    // 核心调用:将对象提交给 JaVers
                    javers.commit(event.getAuthor(), event.getEntity());
                    break;
            }
        } catch (Exception e) {
            log.error("记录实体变更审计失败: {}", event, e);
            throw new RuntimeException("记录实体变更审计失败: " + e.getMessage());
        }
    }
}

重要说明

  • 监听器不加 @Async,是同步执行的。这意味着 JaVers 的 commit() 在同一个数据库事务中完成。
  • 如果 commit() 抛出异常,会向上传播导致业务操作回滚。
  • javers.commit(author, entity) 的两个参数:author 是操作人标识(可以是用户ID或用户名),entity 是待快照的对象(Entity 或 DTO)。

4. 实体与 DTO 的 JaVers 注解规范

4.1 基础实体类(TimeEntity —— 所有实体的父类)

package your.project.common.entity;

import jakarta.persistence.*;
import lombok.Data;
import org.javers.core.metamodel.annotation.DiffIgnore;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;

@Data
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class TimeEntity {

    @Column(updatable = false)
    @CreatedDate
    @DiffIgnore    // 创建时间不纳入版本对比
    private LocalDateTime createTime;

    @Column
    @LastModifiedDate
    @DiffIgnore    // 更新时间不纳入版本对比
    private LocalDateTime updateTime;
}
package your.project.common.entity;

import jakarta.persistence.*;
import lombok.Data;
import lombok.EqualsAndHashCode;

@EqualsAndHashCode(callSuper = true)
@Data
@MappedSuperclass
public abstract class BaseEntity extends TimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.TABLE, generator = "id_generator")
    @TableGenerator(name = "id_generator")
    private Long id;
}

4.2 直接追踪 Entity(模式A)

适用于简单实体,直接对 Entity 类加 JaVers 注解:

package your.project.domain.entity;

import jakarta.persistence.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.javers.core.metamodel.annotation.PropertyName;
import org.javers.core.metamodel.annotation.TypeName;
import your.project.common.entity.BaseEntity;

@Data
@Entity
@EqualsAndHashCode(callSuper = true)
@Table(name = "your_entity")
@TypeName("YourEntity")        // 给 JaVers 看的类型名,建议与类名一致
public class YourEntity extends BaseEntity {

    @Column(length = 100)
    @PropertyName("名称")        // 版本对比时显示的中文名
    private String name;

    @Column(length = 50)
    @PropertyName("状态")
    private String status;

    @Column(length = 200)
    @PropertyName("描述")
    private String description;

    // 不想纳入版本记录的字段加 @DiffIgnore
    @DiffIgnore
    @Column(length = 50)
    private String internalCode;
}

4.3 使用 SnapshotDTO 追踪(模式B)

适用于需要将关联数据也纳入快照的复杂聚合场景。核心思路:JaVers 提交的不是原始 Entity,而是手动构建的 DTO

Step 1:定义 DTO(带 JaVers 注解)

package your.project.domain.dto;

import lombok.Data;
import org.javers.core.metamodel.annotation.Id;
import org.javers.core.metamodel.annotation.PropertyName;
import org.javers.core.metamodel.annotation.TypeName;
import org.javers.core.metamodel.annotation.DiffIgnore;
import java.time.LocalDateTime;
import java.util.List;

@Data
@TypeName("YourAggregateSnapshotDTO")
public class YourAggregateSnapshotDTO {

    @Id                          // JaVers 的 @Id,不是 JPA 的
    @PropertyName("ID")
    private Long id;

    @PropertyName("名称")
    private String name;

    @PropertyName("状态")
    private String status;

    @PropertyName("子项列表")
    private List<ChildSnapshotDTO> children;

    @DiffIgnore
    @PropertyName("创建时间")
    private LocalDateTime createTime;

    /**
     * 工厂方法:从 Entity 构建 DTO
     */
    public static YourAggregateSnapshotDTO from(YourEntity entity, List<ChildEntity> children) {
        YourAggregateSnapshotDTO dto = new YourAggregateSnapshotDTO();
        dto.setId(entity.getId());
        dto.setName(entity.getName());
        dto.setStatus(entity.getStatus());
        dto.setCreateTime(entity.getCreateTime());
        dto.setChildren(children.stream()
                .map(ChildSnapshotDTO::from)
                .toList());
        return dto;
    }
}

Step 2:子项用 @Value 标注(JaVers 的 ValueObject)

package your.project.domain.dto;

import lombok.Value;
import org.javers.core.metamodel.annotation.PropertyName;

@Value    // JaVers 的 @Value 注解,表示这是一个值对象,不独立追踪版本
public class ChildSnapshotDTO {

    @PropertyName("子项ID")
    private Long childId;

    @PropertyName("子项名称")
    private String childName;

    public static ChildSnapshotDTO from(ChildEntity entity) {
        return new ChildSnapshotDTO(entity.getId(), entity.getName());
    }
}

4.4 JaVers 注解速查表

注解 作用 使用位置
@TypeName("名称") 给 JaVers 设置类型名称,影响查询和显示 Entity / DTO 类
@Id 标记 JaVers 的全局唯一标识字段 DTO 的 ID 字段(Entity 用 JPA 的 @Id
@PropertyName("中文名") 给字段设置显示名称,前端对比表显示 所有追踪的字段
@DiffIgnore 忽略该字段,不纳入变更对比和快照 createTime、updateTime、内部编码等
@Value 标记为 JaVers 值对象(无独立版本,跟随父对象) 列表中嵌套的子对象

5. 业务层集成(如何发布事件触发快照)

5.1 基本原则

在业务操作完成后,Controller 返回之前,调用 eventPublisher.publishXxx()

5.2 模式A:直接提交 Entity

@Service
@Transactional
@RequiredArgsConstructor
public class YourBiz {

    private final YourRepository repository;
    private final EntityChangeEventPublisher eventPublisher;

    // 新增
    public YourEntity create(YourCreateParam param) {
        YourEntity entity = new YourEntity();
        entity.setName(param.getName());
        entity.setStatus(param.getStatus());
        entity = repository.save(entity);

        // 发布创建事件 → 触发 JaVers 快照
        eventPublisher.publishCreated(entity);

        return entity;
    }

    // 更新
    public YourEntity update(Long id, YourUpdateParam param) {
        YourEntity entity = repository.findById(id)
                .orElseThrow(() -> new RuntimeException("数据不存在"));
        entity.setName(param.getName());
        entity.setStatus(param.getStatus());
        entity = repository.save(entity);

        // 发布更新事件 → 触发 JaVers 快照
        eventPublisher.publishUpdated(entity);

        return entity;
    }

    // 删除
    public void delete(Long id) {
        YourEntity entity = repository.findById(id)
                .orElseThrow(() -> new RuntimeException("数据不存在"));

        // 先发布删除事件(记录删除前的最后状态)
        eventPublisher.publishDeleted(entity);

        repository.delete(entity);
    }
}

5.3 模式B:提交 DTO(聚合场景)

@Service
@Transactional
@RequiredArgsConstructor
public class YourAggregateBiz {

    private final YourEntityRepository entityRepository;
    private final ChildEntityRepository childRepository;
    private final EntityChangeEventPublisher eventPublisher;

    public void update(Long id, YourUpdateParam param) {
        YourEntity entity = entityRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("数据不存在"));
        entity.setName(param.getName());
        entity.setStatus(param.getStatus());
        entityRepository.save(entity);

        // 加载关联数据
        List<ChildEntity> children = childRepository.findByParentId(id);

        // 构建 DTO 并发布事件
        YourAggregateSnapshotDTO dto = YourAggregateSnapshotDTO.from(entity, children);
        eventPublisher.publishUpdated(dto);
    }
}

5.4 定时任务/系统自动操作中的快照

// 对于非用户触发的操作(如定时同步),使用指定作者
eventPublisher.publishCreated(entity, "system_sync");

// 或使用默认(会自动获取当前登录用户,非 Web 上下文中为 "system")
eventPublisher.publishUpdated(entity);

6. 后端 API 设计(查询、详情、对比)

6.1 JaVersHistoryService —— 通用查询服务

这是所有实体的版本历史查询的唯一实现,所有 Entity 共用。

package your.project.domain.service;

import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.javers.core.Javers;
import org.javers.core.diff.Diff;
import org.javers.core.metamodel.object.CdoSnapshot;
import org.javers.repository.jql.QueryBuilder;
import org.javers.shadow.Shadow;
import org.springframework.stereotype.Service;

import java.util.*;

@Service
@Slf4j
public class JaVersHistoryService {

    private final Javers javers;

    public JaVersHistoryService(Javers javers) {
        this.javers = javers;
    }

    /**
     * 获取实体的历史快照列表(版本元数据,分页)
     *
     * @param entityId    实体ID(数据库主键)
     * @param entityClass 实体类(或 DTO 类)
     * @param limit       每页条数
     * @param skip        跳过条数
     * @return 版本列表(每项包含 version 和 commitMetadata)
     */
    public List<Map<String, Object>> getEntitySnapshots(
            Long entityId, Class<?> entityClass, int limit, int skip) {

        var query = QueryBuilder.byInstanceId(entityId, entityClass)
                .skip(skip)
                .limit(limit)
                .build();

        List<CdoSnapshot> snapshots = javers.findSnapshots(query);

        return snapshots.stream()
                .map(this::convertToMetadata)
                .toList();
    }

    /**
     * 获取指定版本的完整快照数据
     *
     * @param entityId    实体ID
     * @param entityClass 实体类
     * @param version     版本号
     * @return 完整的属性 Map(属性名 → 属性值)
     */
    public Map<String, Object> getEntitySnapshotByVersion(
            Long entityId, int version, Class<?> entityClass) {

        var query = QueryBuilder.byInstanceId(entityId, entityClass)
                .withVersion(version)
                .build();

        List<CdoSnapshot> snapshots = javers.findSnapshots(query);

        if (snapshots.isEmpty()) {
            throw new RuntimeException(String.format(
                    "版本号 %d 不存在,实体ID: %d", version, entityId));
        }

        Map<String, Object> stateMap = new HashMap<>();
        snapshots.get(0).getState().forEachProperty(stateMap::put);
        return stateMap;
    }

    /**
     * 比较两个版本的差异
     *
     * @param entityId    实体ID
     * @param version1    旧版本号
     * @param version2    新版本号
     * @param entityClass 实体类
     * @return JaVers Diff 对象(包含 changes 列表)
     */
    public Diff compareVersions(
            Long entityId, int version1, int version2, Class<?> entityClass) {

        Object olderVersion = getShadow(entityId, version1, entityClass);
        Object newerVersion = getShadow(entityId, version2, entityClass);
        return javers.compare(olderVersion, newerVersion);
    }

    /** 获取指定版本的原始对象 */
    private Object getShadow(Long entityId, int version, Class<?> entityClass) {
        var query = QueryBuilder.byInstanceId(entityId, entityClass)
                .withVersion(version)
                .build();

        List<Shadow<Object>> shadows = javers.findShadows(query);

        if (shadows.isEmpty()) {
            throw new RuntimeException(String.format(
                    "版本号 %d 不存在,实体ID: %d", version, entityId));
        }
        return shadows.get(0).get();
    }

    /** 将 CdoSnapshot 转为精简的元数据 Map */
    private Map<String, Object> convertToMetadata(CdoSnapshot snapshot) {
        Map<String, Object> map = new HashMap<>();
        map.put("version", snapshot.getVersion());

        Map<String, Object> commitMetadata = new HashMap<>();
        String authorId = snapshot.getCommitMetadata().getAuthor();
        commitMetadata.put("author", resolveAuthorName(authorId));
        commitMetadata.put("authorId", authorId);
        commitMetadata.put("commitDate",
                snapshot.getCommitMetadata().getCommitDate());
        map.put("commitMetadata", commitMetadata);

        return map;
    }

    /**
     * 根据作者ID解析显示名称。
     * 你需要替换为你们项目的用户查询方式。
     */
    private String resolveAuthorName(String authorId) {
        if (authorId == null || authorId.isEmpty()) {
            return "未知";
        }
        // 示例:从用户表查询
        // return userRepository.findById(Long.parseLong(authorId))
        //         .map(UserEntity::getDisplayName)
        //         .orElse(authorId);
        return authorId;
    }
}

6.2 Controller 示例

每个需要版本历史的 Entity 在 Controller 中添加 3 个接口。以 YourEntity 为例:

package your.project.interfaces.web;

import org.javers.core.diff.Diff;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/your-api-path/your-entity")
public class YourEntityController {

    @Resource
    private JaVersHistoryService jaVersHistoryService;

    // ============ 版本历史接口 ============

    /** 获取历史快照列表(分页) */
    @GetMapping("snapshot-menu/{entityId}")
    public List<Map<String, Object>> getSnapshots(
            @PathVariable Long entityId,
            @RequestParam(defaultValue = "20") int limit,
            @RequestParam(defaultValue = "0") int skip) {
        return jaVersHistoryService.getEntitySnapshots(
                entityId, YourEntity.class, limit, skip);
    }

    /** 获取指定版本的完整快照 */
    @GetMapping("snapshot/{entityId}/{version}")
    public Map<String, Object> getSnapshotByVersion(
            @PathVariable Long entityId,
            @PathVariable int version) {
        return jaVersHistoryService.getEntitySnapshotByVersion(
                entityId, version, YourEntity.class);
    }

    /** 比较两个版本 */
    @GetMapping("compare/{entityId}")
    public Diff compareVersions(
            @PathVariable Long entityId,
            @RequestParam int version1,
            @RequestParam int version2) {
        return jaVersHistoryService.compareVersions(
                entityId, version1, version2, YourEntity.class);
    }
}

接口路径规范:建议路径格式为 /{业务前缀}/snapshot-menu/{entityId}/{业务前缀}/snapshot/{entityId}/{version}/{业务前缀}/compare/{entityId}

6.3 关键 API 参数说明

参数 类型 说明
entityId Long 实体在数据库中的主键 ID(对应 JaVers 的 instanceId
entityClass Class<?> 传给 JaVers 的类型。直接追踪用 Entity.class,DTO 追踪用 DTO.class
version int 版本号。JaVers 从 1 开始自增,每次 commit 产生一个新版本
limit int 每页条数,默认 20
skip int 跳过的条数,用于分页。前端第 N 页的 skip = N * limit

6.4 Diff 返回结构

compareVersions 返回的 Diff 对象 JSON 序列化后的结构:

{
  "changes": [
    {
      "propertyName": "name",
      "left": "旧名称",
      "right": "新名称"
    },
    {
      "propertyName": "status",
      "left": "pending",
      "right": "completed"
    }
  ]
}

前端直接使用 changes 数组,每项有 propertyName(字段名)、left(旧值)、right(新值)。


7. 已有数据初始化快照

如果系统中已有历史数据(在集成 JaVers 之前就存在的记录),需要做一次性初始化。

package your.project.application.biz;

import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.javers.core.Javers;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Slf4j
public class JaVersInitBiz {

    private final Javers javers;
    private final YourEntityRepository yourEntityRepository;

    /** 为已有数据创建初始快照 */
    @Transactional
    public void initYourEntities() {
        List<YourEntity> entities = yourEntityRepository.findAll();
        log.info("开始初始化 YourEntity 快照,共 {} 条", entities.size());

        for (YourEntity entity : entities) {
            try {
                javers.commit("system_init", entity);
            } catch (Exception e) {
                log.error("初始化快照失败: entityId={}", entity.getId(), e);
            }
        }

        log.info("初始化 YourEntity 快照完成");
    }
}

提供一个后台接口触发:

@RestController
@RequestMapping("/admin")
public class AdminController {

    @Resource
    private JaVersInitBiz jaVersInitBiz;

    @PostMapping("/javers/init")
    public String initSnapshots(@RequestParam(defaultValue = "all") String type) {
        switch (type) {
            case "yourEntity":
                jaVersInitBiz.initYourEntities();
                break;
            case "all":
                jaVersInitBiz.initYourEntities();
                // 可以继续添加其他实体
                break;
        }
        return "初始化完成";
    }
}

8. 前端 HistoryDialog 组件

8.1 组件概述

HistoryDialog.vue 是一个通用的版本历史弹窗组件,左右分栏布局:

+----------------------------+--------------------------------------+
|  版本列表 (200px)           |  详情/对比区                          |
|                            |                                      |
|  [对比] 按钮(需选2个版本)  |  对比模式:表格(属性 | 旧值 | 新值)  |
|                            |                                      |
|  [x] V3 张三               |  单版本模式:JSON 格式完整快照          |
|  2024-01-03 10:00          |                                      |
|                            |                                      |
|  [x] V2 李四  <-- 高亮     |                                      |
|  2024-01-02 10:00          |                                      |
|                            |                                      |
|  [ ] V1 王五               |                                      |
|  2024-01-01 10:00          |                                      |
|                            |                                      |
|  ...(无限滚动加载更多)     |                                      |
+----------------------------+--------------------------------------+

8.2 完整组件代码

<template>
  <el-dialog
    v-model="dialogVisible"
    :title="title"
    width="70%"
    :close-on-click-modal="false"
    :destroy-on-close="true"
  >
    <div class="version-history-layout">
      <!-- 左侧版本列表 -->
      <div class="version-list-panel">
        <div class="version-list-header">
          <span>版本列表</span>
          <el-button
            type="primary"
            size="small"
            :disabled="selectedVersions.length !== 2"
            @click="handleCompare"
          >
            对比
          </el-button>
        </div>
        <el-scrollbar ref="scrollbarRef" @scroll="handleScroll">
          <div v-if="snapshots.length === 0 && !isLoading" class="no-versions">
            <el-empty description="暂无数据" :image-size="60" />
          </div>
          <div v-else>
            <div
              class="version-item"
              :class="{ active: selectedVersion === item.version }"
              v-for="item in snapshots"
              :key="item.version"
              @click="selectVersion(item.version)"
            >
              <el-checkbox
                :model-value="selectedVersions.includes(item.version)"
                @change="handleVersionCheck(item.version)"
                :disabled="!selectedVersions.includes(item.version) && selectedVersions.length >= 2"
                @click.stop
              />
              <div class="version-content">
                <div class="version-header">
                  <span class="version-name">V{{ item.version }}</span>
                  <span class="version-author">{{ item.commitMetadata?.author ?? '-' }}</span>
                </div>
                <div class="version-time">{{ formatDateTime(item.commitMetadata?.commitDate) }}</div>
              </div>
            </div>
            <div v-if="isLoading" class="loading-more">
              <span>加载中...</span>
            </div>
            <div v-if="noMoreData && snapshots.length > 0" class="no-more">
              没有更多数据了
            </div>
          </div>
        </el-scrollbar>
      </div>

      <!-- 右侧展示区 -->
      <div class="version-detail-panel">
        <!-- 对比结果 -->
        <div v-if="compareResult" class="compare-container">
          <div class="compare-header">
            <span class="compare-title">
              V{{ compareVersion1 }} → V{{ compareVersion2 }}
            </span>
            <el-button size="small" type="danger" @click="closeCompare">关闭对比</el-button>
          </div>
          <el-table :data="compareResult.changes" border stripe max-height="400">
            <el-table-column prop="propertyName" label="属性" min-width="150" />
            <el-table-column label="旧值" min-width="200">
              <template #default="{ row }">
                <pre class="compare-value old-value">{{ formatCompareValue(row.left) }}</pre>
              </template>
            </el-table-column>
            <el-table-column label="新值" min-width="200">
              <template #default="{ row }">
                <pre class="compare-value new-value">{{ formatCompareValue(row.right) }}</pre>
              </template>
            </el-table-column>
          </el-table>
          <div class="no-changes" v-if="compareResult.changes.length === 0">
            <el-empty description="两个版本没有变更" />
          </div>
        </div>
        <!-- 单版本详情 -->
        <div v-else-if="selectedSnapshotData" class="snapshot-detail">
          <pre class="json-data">{{ formatJsonData(selectedSnapshotData) }}</pre>
        </div>
        <div v-else class="no-data">
          <el-empty description="请选择一个版本查看详情" />
        </div>
      </div>
    </div>
    <template #footer>
      <el-button @click="close">关闭</el-button>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref } from "vue";
import { ElMessage } from "element-plus";

const emit = defineEmits(["close"]);

// ============ 状态 ============
const title = ref("历史记录");
let getSnapshotsFn = null;
let getSnapshotByVersionFn = null;
let compareVersionsFn = null;
let customValueFormatter = null;

const dialogVisible = ref(false);
const snapshots = ref([]);
const selectedVersion = ref(null);
const selectedVersions = ref([]);
const compareVersion1 = ref(null);
const compareVersion2 = ref(null);
const compareResult = ref(null);
const selectedSnapshotData = ref(null);
const isLoading = ref(false);
const noMoreData = ref(false);
const currentPage = ref(0);
const pageSize = ref(20);
const currentEntityId = ref(null);
const snapshotDataCache = ref(new Map());
const scrollbarRef = ref(null);

// ============ 版本选择 ============
const selectVersion = async (version) => {
  selectedVersion.value = version;
  if (snapshotDataCache.value.has(version)) {
    selectedSnapshotData.value = snapshotDataCache.value.get(version);
    return;
  }
  try {
    isLoading.value = true;
    const stateData = await getSnapshotByVersionFn(currentEntityId.value, version);
    snapshotDataCache.value.set(version, stateData);
    selectedSnapshotData.value = stateData;
  } catch (error) {
    ElMessage.error("加载版本详情失败");
  } finally {
    isLoading.value = false;
  }
};

// ============ 版本对比 ============
const handleVersionCheck = (version) => {
  const index = selectedVersions.value.indexOf(version);
  if (index > -1) {
    selectedVersions.value.splice(index, 1);
  } else if (selectedVersions.value.length >= 2) {
    ElMessage.warning("最多只能选择两个版本");
  } else {
    selectedVersions.value.push(version);
  }
};

const handleCompare = async () => {
  if (selectedVersions.value.length !== 2) return;
  const v1 = Math.min(...selectedVersions.value);
  const v2 = Math.max(...selectedVersions.value);
  compareVersion1.value = v1;
  compareVersion2.value = v2;
  try {
    const res = await compareVersionsFn(currentEntityId.value, v1, v2);
    compareResult.value = res;
  } catch (error) {
    ElMessage.error("版本对比失败");
  }
};

const closeCompare = () => {
  compareResult.value = null;
  compareVersion1.value = null;
  compareVersion2.value = null;
};

// ============ 分页加载 ============
const loadSnapshots = async () => {
  if (isLoading.value || noMoreData.value) return;
  isLoading.value = true;
  try {
    const skip = currentPage.value * pageSize.value;
    const res = await getSnapshotsFn(currentEntityId.value, {
      limit: pageSize.value,
      skip,
    });
    if (!res || res.length === 0) {
      noMoreData.value = true;
    } else {
      if (res.length < pageSize.value) noMoreData.value = true;
      snapshots.value = currentPage.value === 0
        ? res
        : [...snapshots.value, ...res];
      currentPage.value++;
      if (currentPage.value === 1 && snapshots.value.length > 0) {
        await selectVersion(snapshots.value[0].version);
      }
    }
  } catch {
    ElMessage.error("加载历史记录失败");
  } finally {
    isLoading.value = false;
  }
};

const handleScroll = () => {
  if (!scrollbarRef.value) return;
  const wrap = scrollbarRef.value.$el.querySelector(".el-scrollbar__wrap");
  if (!wrap) return;
  if (wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < 50) {
    loadSnapshots();
  }
};

// ============ 公开方法 ============
const open = async (entityId, getSnapshots, getSnapshotByVersion, compareVersions, labelMapObj, titleStr, valueFormatter) => {
  dialogVisible.value = true;
  selectedVersion.value = null;
  selectedVersions.value = [];
  compareResult.value = null;
  selectedSnapshotData.value = null;
  currentEntityId.value = entityId;
  currentPage.value = 0;
  snapshots.value = [];
  noMoreData.value = false;
  snapshotDataCache.value = new Map();

  getSnapshotsFn = getSnapshots;
  getSnapshotByVersionFn = getSnapshotByVersion;
  compareVersionsFn = compareVersions;
  customValueFormatter = valueFormatter || null;

  if (titleStr) title.value = titleStr;
  await loadSnapshots();
};

const close = () => {
  dialogVisible.value = false;
  emit("close");
};

defineExpose({ open, close });

// ============ 格式化工具 ============
const formatDateTime = (dateStr) => {
  if (!dateStr) return "-";
  const date = new Date(dateStr);
  if (isNaN(date.getTime())) return dateStr;
  const pad = (n) => String(n).padStart(2, "0");
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};

const formatCompareValue = (value) => {
  if (value === null || value === undefined) return "-";
  if (typeof value === "object") return JSON.stringify(value, null, 2);
  return String(value);
};

const formatJsonData = (data) => {
  if (data === null || data === undefined) return "-";
  if (typeof data !== "object") return String(data);
  return JSON.stringify(data, null, 2);
};
</script>

<style lang="scss" scoped>
.version-history-layout {
  display: flex;
  height: 500px;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  overflow: hidden;
}

.version-list-panel {
  width: 200px;
  border-right: 1px solid #e4e7ed;
  background: #f5f7fa;
  display: flex;
  flex-direction: column;
  flex-shrink: 0;

  .version-list-header {
    padding: 10px 12px;
    font-weight: 500;
    color: #303133;
    border-bottom: 1px solid #e4e7ed;
    background: #ebeef5;
    font-size: 13px;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .no-versions {
    padding: 40px 20px;
    display: flex;
    justify-content: center;
    align-items: center;
  }

  .version-item {
    padding: 8px 12px;
    border-bottom: 1px solid #ebeef5;
    transition: all 0.2s;
    display: flex;
    align-items: center;
    gap: 8px;
    cursor: pointer;

    &:hover { background: #e4e7ed; }

    &.active {
      background: #409eff;
      color: #fff;
      .version-time { color: rgba(255, 255, 255, 0.8); }
    }

    .version-content { flex: 1; min-width: 0; }

    .version-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 2px;

      .version-name { font-weight: 500; font-size: 14px; }

      .version-author {
        font-size: 12px;
        color: #909399;
        max-width: 80px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
    }

    .version-time { font-size: 12px; color: #909399; }
  }
}

.version-detail-panel {
  flex: 1;
  padding: 16px;
  overflow-y: auto;
}

.snapshot-detail .json-data {
  margin: 0;
  padding: 12px;
  background: #f5f7fa;
  border-radius: 4px;
  font-family: monospace;
  font-size: 13px;
  line-height: 1.6;
  color: #303133;
  overflow-x: auto;
  white-space: pre-wrap;
  word-break: break-all;
}

.compare-container {
  .compare-header {
    margin-bottom: 16px;
    padding: 12px;
    background: #f5f7fa;
    border-radius: 4px;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .compare-value {
    margin: 0;
    padding: 4px 8px;
    background: #fafafa;
    border-radius: 4px;
    font-family: monospace;
    font-size: 12px;
    line-height: 1.5;
    white-space: pre-wrap;
    word-break: break-all;
    max-height: 200px;
    overflow-y: auto;
  }

  .old-value { color: #f56c6c; background: #fef0f0; }
  .new-value { color: #67c23a; background: #f0f9eb; }
}

.no-data, .no-changes {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
}

.loading-more, .no-more {
  text-align: center;
  padding: 12px;
  color: #909399;
  font-size: 13px;
}
</style>

8.3 组件 Props 说明(通过 open() 方法传入)

参数 类型 说明
entityId Number 实体在数据库中的主键 ID
getSnapshots Function API函数:(entityId, { limit, skip }) => Promise<Array>
getSnapshotByVersion Function API函数:(entityId, version) => Promise<Object>
compareVersions Function API函数:(entityId, version1, version2) => Promise<Object>
labelMapObj Object 字段名到中文标签的映射,如 { name: "名称" }
titleStr String 弹窗标题
valueFormatter Function 可选,自定义值格式化:(key, value) => formattedString

9. 前端 API 层

每个实体需要在 API 文件中添加 3 个函数:

// yourApi.js
import request from "@/utils/request"; // 你们的 HTTP 客户端

const BASE = "/your-api-path/your-entity";

// 获取历史快照列表
export function getYourEntitySnapshots(entityId, params) {
  return request.get(`${BASE}/snapshot-menu/${entityId}`, { params });
}

// 获取指定版本完整快照
export function getYourEntitySnapshot(entityId, version) {
  return request.get(`${BASE}/snapshot/${entityId}/${version}`);
}

// 比较两个版本
export function compareYourEntityVersions(entityId, version1, version2) {
  return request.get(`${BASE}/compare/${entityId}`, {
    params: { version1, version2 },
  });
}

10. 页面集成示例

在列表页添加"变更历史"按钮

<template>
  <div>
    <!-- 你的列表 -->
    <el-table :data="list">
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button link type="primary" @click="showHistory(row)">
            变更历史
          </el-button>
        </template>
      </el-table-column>
    </el-table>

    <!-- HistoryDialog -->
    <HistoryDialog ref="historyRef" />
  </div>
</template>

<script setup>
import { ref } from "vue";
import HistoryDialog from "@/base-components/HistoryDialog.vue";
import {
  getYourEntitySnapshots,
  getYourEntitySnapshot,
  compareYourEntityVersions,
} from "@/api/data/yourApi";

const historyRef = ref(null);

const showHistory = (row) => {
  historyRef.value.open(
    row.id,                      // 实体ID
    getYourEntitySnapshots,      // 快照列表API
    getYourEntitySnapshot,       // 单版本详情API
    compareYourEntityVersions,   // 版本对比API
    {
      // labelMap:字段名 -> 中文名
      id: "ID",
      name: "名称",
      status: "状态",
      description: "描述",
      createTime: "创建时间",
    },
    "实体名称-变更历史"            // 弹窗标题
  );
};
</script>

labelMap 映射规则

  • 如果 Entity 使用了 @PropertyName("中文名")propertyName 会是中文名,labelMap 可以传空对象 {}
  • 如果 Entity 没使用 @PropertyName,前端拿到的 propertyName 是 Java 字段名(英文),此时 labelMap 必须做映射

11. 完整实施步骤清单

按以下顺序实施,每步均可在局部验证:

后端步骤

前端步骤

验证清单


常见问题

Q: 为什么不直接用 @JaversSpringDataAuditable

A: 自动审计虽然简单,但有两个问题:(1) 每次 repository.save() 都触发,无法区分临时保存和最终提交;(2) 只能提交原始 Entity,无法提交聚合 DTO。事件驱动方式更灵活,业务代码明确控制何时提交。

Q: DTO 模式和直接 Entity 模式怎么选?

A: 如果实体只包含自身字段(无关联数据),直接用 Entity 模式。如果需要在快照中包含关联数据(如"项目-详情-迭代"的聚合视图),用 DTO 模式。Entity 模式更简单,DTO 模式更强大。

Q: JaVers 快照会不会让数据库越来越大?

A: 每次 commit 产生一条快照记录(JSON 存储)。对于普通业务实体(几百到几千条,修改频率不高),增长非常缓慢。JaVers 不提供自动清理功能,如需清理可自行按 commitDate 删除历史快照。

Q: 如果 Entity 加了新字段,旧快照中该字段值是什么?

A: 旧快照中没有该字段(因为提交时不包含),前端查询详情时会显示为 undefined。这通常是可接受的行为。如果需要在展示时处理,可以在 valueFormatter 中对缺失字段做默认值处理。


文章来源:https://www.cnblogs.com/xudong1990/p/20209231
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!

标签:

相关文章

本站推荐

标签云