From 039d482517240684a11268e1401cbbc6a1c3c737 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 8 Sep 2025 11:27:35 +0800 Subject: [PATCH] Add post change log tracking --- .../controller/AdminPostController.java | 16 ++-- .../controller/PostChangeLogController.java | 25 +++++ .../com/openisle/dto/PostChangeLogDto.java | 30 ++++++ .../openisle/mapper/PostChangeLogMapper.java | 39 ++++++++ .../openisle/model/PostCategoryChangeLog.java | 17 ++++ .../com/openisle/model/PostChangeLog.java | 37 ++++++++ .../com/openisle/model/PostChangeType.java | 11 +++ .../openisle/model/PostClosedChangeLog.java | 17 ++++ .../openisle/model/PostContentChangeLog.java | 21 +++++ .../openisle/model/PostFeaturedChangeLog.java | 17 ++++ .../openisle/model/PostPinnedChangeLog.java | 19 ++++ .../com/openisle/model/PostTagChangeLog.java | 21 +++++ .../openisle/model/PostTitleChangeLog.java | 17 ++++ .../repository/PostChangeLogRepository.java | 11 +++ .../service/PostChangeLogService.java | 94 +++++++++++++++++++ .../com/openisle/service/PostService.java | 73 +++++++++++--- .../components/PostChangeLogItem.vue | 44 +++++++++ frontend_nuxt/pages/posts/[id]/index.vue | 64 +++++++++++-- 18 files changed, 543 insertions(+), 30 deletions(-) create mode 100644 backend/src/main/java/com/openisle/controller/PostChangeLogController.java create mode 100644 backend/src/main/java/com/openisle/dto/PostChangeLogDto.java create mode 100644 backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java create mode 100644 backend/src/main/java/com/openisle/model/PostCategoryChangeLog.java create mode 100644 backend/src/main/java/com/openisle/model/PostChangeLog.java create mode 100644 backend/src/main/java/com/openisle/model/PostChangeType.java create mode 100644 backend/src/main/java/com/openisle/model/PostClosedChangeLog.java create mode 100644 backend/src/main/java/com/openisle/model/PostContentChangeLog.java create mode 100644 backend/src/main/java/com/openisle/model/PostFeaturedChangeLog.java create mode 100644 backend/src/main/java/com/openisle/model/PostPinnedChangeLog.java create mode 100644 backend/src/main/java/com/openisle/model/PostTagChangeLog.java create mode 100644 backend/src/main/java/com/openisle/model/PostTitleChangeLog.java create mode 100644 backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java create mode 100644 backend/src/main/java/com/openisle/service/PostChangeLogService.java create mode 100644 frontend_nuxt/components/PostChangeLogItem.vue diff --git a/backend/src/main/java/com/openisle/controller/AdminPostController.java b/backend/src/main/java/com/openisle/controller/AdminPostController.java index 3c9414cad..498c05add 100644 --- a/backend/src/main/java/com/openisle/controller/AdminPostController.java +++ b/backend/src/main/java/com/openisle/controller/AdminPostController.java @@ -37,22 +37,22 @@ public class AdminPostController { } @PostMapping("/{id}/pin") - public PostSummaryDto pin(@PathVariable Long id) { - return postMapper.toSummaryDto(postService.pinPost(id)); + public PostSummaryDto pin(@PathVariable Long id, org.springframework.security.core.Authentication auth) { + return postMapper.toSummaryDto(postService.pinPost(id, auth.getName())); } @PostMapping("/{id}/unpin") - public PostSummaryDto unpin(@PathVariable Long id) { - return postMapper.toSummaryDto(postService.unpinPost(id)); + public PostSummaryDto unpin(@PathVariable Long id, org.springframework.security.core.Authentication auth) { + return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName())); } @PostMapping("/{id}/rss-exclude") - public PostSummaryDto excludeFromRss(@PathVariable Long id) { - return postMapper.toSummaryDto(postService.excludeFromRss(id)); + public PostSummaryDto excludeFromRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) { + return postMapper.toSummaryDto(postService.excludeFromRss(id, auth.getName())); } @PostMapping("/{id}/rss-include") - public PostSummaryDto includeInRss(@PathVariable Long id) { - return postMapper.toSummaryDto(postService.includeInRss(id)); + public PostSummaryDto includeInRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) { + return postMapper.toSummaryDto(postService.includeInRss(id, auth.getName())); } } diff --git a/backend/src/main/java/com/openisle/controller/PostChangeLogController.java b/backend/src/main/java/com/openisle/controller/PostChangeLogController.java new file mode 100644 index 000000000..ae51a9dbb --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/PostChangeLogController.java @@ -0,0 +1,25 @@ +package com.openisle.controller; + +import com.openisle.dto.PostChangeLogDto; +import com.openisle.mapper.PostChangeLogMapper; +import com.openisle.service.PostChangeLogService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/posts") +@RequiredArgsConstructor +public class PostChangeLogController { + private final PostChangeLogService changeLogService; + private final PostChangeLogMapper mapper; + + @GetMapping("/{id}/change-logs") + public List listLogs(@PathVariable Long id) { + return changeLogService.listLogs(id).stream() + .map(mapper::toDto) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java b/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java new file mode 100644 index 000000000..57335a56f --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java @@ -0,0 +1,30 @@ +package com.openisle.dto; + +import com.openisle.model.PostChangeType; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +public class PostChangeLogDto { + private Long id; + private String username; + private PostChangeType type; + private LocalDateTime time; + private String oldTitle; + private String newTitle; + private String oldContent; + private String newContent; + private String oldCategory; + private String newCategory; + private String oldTags; + private String newTags; + private Boolean oldClosed; + private Boolean newClosed; + private LocalDateTime oldPinnedAt; + private LocalDateTime newPinnedAt; + private Boolean oldFeatured; + private Boolean newFeatured; +} diff --git a/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java b/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java new file mode 100644 index 000000000..025793986 --- /dev/null +++ b/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java @@ -0,0 +1,39 @@ +package com.openisle.mapper; + +import com.openisle.dto.PostChangeLogDto; +import com.openisle.model.*; +import org.springframework.stereotype.Component; + +@Component +public class PostChangeLogMapper { + public PostChangeLogDto toDto(PostChangeLog log) { + PostChangeLogDto dto = new PostChangeLogDto(); + dto.setId(log.getId()); + dto.setUsername(log.getUser().getUsername()); + dto.setType(log.getType()); + dto.setTime(log.getCreatedAt()); + if (log instanceof PostTitleChangeLog t) { + dto.setOldTitle(t.getOldTitle()); + dto.setNewTitle(t.getNewTitle()); + } else if (log instanceof PostContentChangeLog c) { + dto.setOldContent(c.getOldContent()); + dto.setNewContent(c.getNewContent()); + } else if (log instanceof PostCategoryChangeLog cat) { + dto.setOldCategory(cat.getOldCategory()); + dto.setNewCategory(cat.getNewCategory()); + } else if (log instanceof PostTagChangeLog tag) { + dto.setOldTags(tag.getOldTags()); + dto.setNewTags(tag.getNewTags()); + } else if (log instanceof PostClosedChangeLog cl) { + dto.setOldClosed(cl.isOldClosed()); + dto.setNewClosed(cl.isNewClosed()); + } else if (log instanceof PostPinnedChangeLog p) { + dto.setOldPinnedAt(p.getOldPinnedAt()); + dto.setNewPinnedAt(p.getNewPinnedAt()); + } else if (log instanceof PostFeaturedChangeLog f) { + dto.setOldFeatured(f.isOldFeatured()); + dto.setNewFeatured(f.isNewFeatured()); + } + return dto; + } +} diff --git a/backend/src/main/java/com/openisle/model/PostCategoryChangeLog.java b/backend/src/main/java/com/openisle/model/PostCategoryChangeLog.java new file mode 100644 index 000000000..20264e449 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostCategoryChangeLog.java @@ -0,0 +1,17 @@ +package com.openisle.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_category_change_logs") +public class PostCategoryChangeLog extends PostChangeLog { + private String oldCategory; + private String newCategory; +} diff --git a/backend/src/main/java/com/openisle/model/PostChangeLog.java b/backend/src/main/java/com/openisle/model/PostChangeLog.java new file mode 100644 index 000000000..d577fdbf9 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostChangeLog.java @@ -0,0 +1,37 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_change_logs") +@Inheritance(strategy = InheritanceType.JOINED) +public abstract class PostChangeLog { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "post_id") + private Post post; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PostChangeType type; +} diff --git a/backend/src/main/java/com/openisle/model/PostChangeType.java b/backend/src/main/java/com/openisle/model/PostChangeType.java new file mode 100644 index 000000000..5cff69910 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostChangeType.java @@ -0,0 +1,11 @@ +package com.openisle.model; + +public enum PostChangeType { + CONTENT, + TITLE, + CATEGORY, + TAG, + CLOSED, + PINNED, + FEATURED +} diff --git a/backend/src/main/java/com/openisle/model/PostClosedChangeLog.java b/backend/src/main/java/com/openisle/model/PostClosedChangeLog.java new file mode 100644 index 000000000..20bbc9cb9 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostClosedChangeLog.java @@ -0,0 +1,17 @@ +package com.openisle.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_closed_change_logs") +public class PostClosedChangeLog extends PostChangeLog { + private boolean oldClosed; + private boolean newClosed; +} diff --git a/backend/src/main/java/com/openisle/model/PostContentChangeLog.java b/backend/src/main/java/com/openisle/model/PostContentChangeLog.java new file mode 100644 index 000000000..1f4170577 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostContentChangeLog.java @@ -0,0 +1,21 @@ +package com.openisle.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_content_change_logs") +public class PostContentChangeLog extends PostChangeLog { + @Column(name = "old_content", columnDefinition = "LONGTEXT") + private String oldContent; + + @Column(name = "new_content", columnDefinition = "LONGTEXT") + private String newContent; +} diff --git a/backend/src/main/java/com/openisle/model/PostFeaturedChangeLog.java b/backend/src/main/java/com/openisle/model/PostFeaturedChangeLog.java new file mode 100644 index 000000000..522dba086 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostFeaturedChangeLog.java @@ -0,0 +1,17 @@ +package com.openisle.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_featured_change_logs") +public class PostFeaturedChangeLog extends PostChangeLog { + private boolean oldFeatured; + private boolean newFeatured; +} diff --git a/backend/src/main/java/com/openisle/model/PostPinnedChangeLog.java b/backend/src/main/java/com/openisle/model/PostPinnedChangeLog.java new file mode 100644 index 000000000..395e0f499 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostPinnedChangeLog.java @@ -0,0 +1,19 @@ +package com.openisle.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_pinned_change_logs") +public class PostPinnedChangeLog extends PostChangeLog { + private LocalDateTime oldPinnedAt; + private LocalDateTime newPinnedAt; +} diff --git a/backend/src/main/java/com/openisle/model/PostTagChangeLog.java b/backend/src/main/java/com/openisle/model/PostTagChangeLog.java new file mode 100644 index 000000000..f17f53ad3 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostTagChangeLog.java @@ -0,0 +1,21 @@ +package com.openisle.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_tag_change_logs") +public class PostTagChangeLog extends PostChangeLog { + @Column(name = "old_tags") + private String oldTags; + + @Column(name = "new_tags") + private String newTags; +} diff --git a/backend/src/main/java/com/openisle/model/PostTitleChangeLog.java b/backend/src/main/java/com/openisle/model/PostTitleChangeLog.java new file mode 100644 index 000000000..f7e6dcb2e --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostTitleChangeLog.java @@ -0,0 +1,17 @@ +package com.openisle.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_title_change_logs") +public class PostTitleChangeLog extends PostChangeLog { + private String oldTitle; + private String newTitle; +} diff --git a/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java b/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java new file mode 100644 index 000000000..c9e700fc0 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java @@ -0,0 +1,11 @@ +package com.openisle.repository; + +import com.openisle.model.Post; +import com.openisle.model.PostChangeLog; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PostChangeLogRepository extends JpaRepository { + List findByPostOrderByCreatedAtAsc(Post post); +} diff --git a/backend/src/main/java/com/openisle/service/PostChangeLogService.java b/backend/src/main/java/com/openisle/service/PostChangeLogService.java new file mode 100644 index 000000000..b88f43de2 --- /dev/null +++ b/backend/src/main/java/com/openisle/service/PostChangeLogService.java @@ -0,0 +1,94 @@ +package com.openisle.service; + +import com.openisle.model.*; +import com.openisle.repository.PostChangeLogRepository; +import com.openisle.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PostChangeLogService { + private final PostChangeLogRepository logRepository; + private final PostRepository postRepository; + + public void recordContentChange(Post post, User user, String oldContent, String newContent) { + PostContentChangeLog log = new PostContentChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.CONTENT); + log.setOldContent(oldContent); + log.setNewContent(newContent); + logRepository.save(log); + } + + public void recordTitleChange(Post post, User user, String oldTitle, String newTitle) { + PostTitleChangeLog log = new PostTitleChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.TITLE); + log.setOldTitle(oldTitle); + log.setNewTitle(newTitle); + logRepository.save(log); + } + + public void recordCategoryChange(Post post, User user, String oldCategory, String newCategory) { + PostCategoryChangeLog log = new PostCategoryChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.CATEGORY); + log.setOldCategory(oldCategory); + log.setNewCategory(newCategory); + logRepository.save(log); + } + + public void recordTagChange(Post post, User user, Set oldTags, Set newTags) { + PostTagChangeLog log = new PostTagChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.TAG); + log.setOldTags(oldTags.stream().map(Tag::getName).collect(Collectors.joining(","))); + log.setNewTags(newTags.stream().map(Tag::getName).collect(Collectors.joining(","))); + logRepository.save(log); + } + + public void recordClosedChange(Post post, User user, boolean oldClosed, boolean newClosed) { + PostClosedChangeLog log = new PostClosedChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.CLOSED); + log.setOldClosed(oldClosed); + log.setNewClosed(newClosed); + logRepository.save(log); + } + + public void recordPinnedChange(Post post, User user, java.time.LocalDateTime oldPinnedAt, java.time.LocalDateTime newPinnedAt) { + PostPinnedChangeLog log = new PostPinnedChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.PINNED); + log.setOldPinnedAt(oldPinnedAt); + log.setNewPinnedAt(newPinnedAt); + logRepository.save(log); + } + + public void recordFeaturedChange(Post post, User user, boolean oldFeatured, boolean newFeatured) { + PostFeaturedChangeLog log = new PostFeaturedChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.FEATURED); + log.setOldFeatured(oldFeatured); + log.setNewFeatured(newFeatured); + logRepository.save(log); + } + + public List listLogs(Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + return logRepository.findByPostOrderByCreatedAtAsc(post); + } +} diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index b0016427e..f868cce09 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -19,6 +19,7 @@ import com.openisle.repository.CategoryRepository; import com.openisle.repository.TagRepository; import com.openisle.service.SubscriptionService; import com.openisle.service.CommentService; +import com.openisle.service.PostChangeLogService; import com.openisle.repository.CommentRepository; import com.openisle.repository.ReactionRepository; import com.openisle.repository.PostSubscriptionRepository; @@ -74,6 +75,7 @@ public class PostService { private final EmailSender emailSender; private final ApplicationContext applicationContext; private final PointService pointService; + private final PostChangeLogService postChangeLogService; private final ConcurrentMap> scheduledFinalizations = new ConcurrentHashMap<>(); @Value("${app.website-url:https://www.open-isle.com}") private String websiteUrl; @@ -99,6 +101,7 @@ public class PostService { EmailSender emailSender, ApplicationContext applicationContext, PointService pointService, + PostChangeLogService postChangeLogService, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { this.postRepository = postRepository; this.userRepository = userRepository; @@ -120,6 +123,7 @@ public class PostService { this.emailSender = emailSender; this.applicationContext = applicationContext; this.pointService = pointService; + this.postChangeLogService = postChangeLogService; this.publishMode = publishMode; } @@ -159,19 +163,28 @@ public class PostService { return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable); } - public Post excludeFromRss(Long id) { + public Post excludeFromRss(Long id, String username) { Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded()); post.setRssExcluded(true); - return postRepository.save(post); + Post saved = postRepository.save(post); + postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, false); + return saved; } - public Post includeInRss(Long id) { + public Post includeInRss(Long id, String username) { Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded()); post.setRssExcluded(false); - post = postRepository.save(post); - notificationService.createNotification(post.getAuthor(), NotificationType.POST_FEATURED, post, null, null, null, null, null); - pointService.awardForFeatured(post.getAuthor().getUsername(), post.getId()); - return post; + Post saved = postRepository.save(post); + postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, true); + notificationService.createNotification(saved.getAuthor(), NotificationType.POST_FEATURED, saved, null, null, null, null, null); + pointService.awardForFeatured(saved.getAuthor().getUsername(), saved.getId()); + return saved; } public Post createPost(String username, @@ -638,18 +651,28 @@ public class PostService { return post; } - public Post pinPost(Long id) { + public Post pinPost(Long id, String username) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + java.time.LocalDateTime oldPinned = post.getPinnedAt(); post.setPinnedAt(java.time.LocalDateTime.now()); - return postRepository.save(post); + Post saved = postRepository.save(post); + postChangeLogService.recordPinnedChange(saved, user, oldPinned, saved.getPinnedAt()); + return saved; } - public Post unpinPost(Long id) { + public Post unpinPost(Long id, String username) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + java.time.LocalDateTime oldPinned = post.getPinnedAt(); post.setPinnedAt(null); - return postRepository.save(post); + Post saved = postRepository.save(post); + postChangeLogService.recordPinnedChange(saved, user, oldPinned, null); + return saved; } public Post closePost(Long id, String username) { @@ -660,8 +683,11 @@ public class PostService { if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { throw new IllegalArgumentException("Unauthorized"); } + boolean oldClosed = post.isClosed(); post.setClosed(true); - return postRepository.save(post); + Post saved = postRepository.save(post); + postChangeLogService.recordClosedChange(saved, user, oldClosed, true); + return saved; } public Post reopenPost(Long id, String username) { @@ -672,8 +698,11 @@ public class PostService { if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { throw new IllegalArgumentException("Unauthorized"); } + boolean oldClosed = post.isClosed(); post.setClosed(false); - return postRepository.save(post); + Post saved = postRepository.save(post); + postChangeLogService.recordClosedChange(saved, user, oldClosed, false); + return saved; } @org.springframework.transaction.annotation.Transactional @@ -702,14 +731,30 @@ public class PostService { if (tags.isEmpty()) { throw new IllegalArgumentException("Tag not found"); } - post.setTitle(title); + String oldTitle = post.getTitle(); String oldContent = post.getContent(); + Category oldCategory = post.getCategory(); + java.util.Set oldTags = new java.util.HashSet<>(post.getTags()); + post.setTitle(title); post.setContent(content); post.setCategory(category); post.setTags(new java.util.HashSet<>(tags)); Post updated = postRepository.save(post); imageUploader.adjustReferences(oldContent, content); notificationService.notifyMentions(content, user, updated, null); + if (!java.util.Objects.equals(oldTitle, title)) { + postChangeLogService.recordTitleChange(updated, user, oldTitle, title); + } + if (!java.util.Objects.equals(oldContent, content)) { + postChangeLogService.recordContentChange(updated, user, oldContent, content); + } + if (!java.util.Objects.equals(oldCategory.getId(), category.getId())) { + postChangeLogService.recordCategoryChange(updated, user, oldCategory.getName(), category.getName()); + } + java.util.Set newTags = new java.util.HashSet<>(tags); + if (!oldTags.equals(newTags)) { + postChangeLogService.recordTagChange(updated, user, oldTags, newTags); + } return updated; } diff --git a/frontend_nuxt/components/PostChangeLogItem.vue b/frontend_nuxt/components/PostChangeLogItem.vue new file mode 100644 index 000000000..d962171b6 --- /dev/null +++ b/frontend_nuxt/components/PostChangeLogItem.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index ea2410981..3ddb9c7ad 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -122,9 +122,10 @@
- +
@@ -182,6 +184,7 @@ import { useRoute } from 'vue-router' import CommentItem from '~/components/CommentItem.vue' import CommentEditor from '~/components/CommentEditor.vue' import BaseTimeline from '~/components/BaseTimeline.vue' +import PostChangeLogItem from '~/components/PostChangeLogItem.vue' import ArticleTags from '~/components/ArticleTags.vue' import ArticleCategory from '~/components/ArticleCategory.vue' import ReactionsGroup from '~/components/ReactionsGroup.vue' @@ -212,6 +215,7 @@ const category = ref('') const tags = ref([]) const postReactions = ref([]) const comments = ref([]) +const changeLogs = ref([]) const status = ref('PUBLISHED') const closed = ref(false) const pinnedAt = ref(null) @@ -225,6 +229,11 @@ const subscribed = ref(false) const commentSort = ref('NEWEST') const isFetchingComments = ref(false) const isMobile = useIsMobile() +const timelineItems = computed(() => { + const cs = comments.value.map((c) => ({ ...c, kind: 'comment' })) + const ls = changeLogs.value.map((l) => ({ ...l, kind: 'log' })) + return [...cs, ...ls].sort((a, b) => new Date(a.time) - new Date(b.time)) +}) const headerHeight = import.meta.client ? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0 @@ -290,8 +299,13 @@ const gatherPostItems = () => { const main = mainContainer.value.querySelector('.info-content-container') if (main) items.push({ el: main, top: getTop(main) }) - for (const c of comments.value) { - const el = document.getElementById('comment-' + c.id) + for (const c of timelineItems.value) { + let el + if (c.kind === 'comment') { + el = document.getElementById('comment-' + c.id) + } else { + el = document.getElementById('change-log-' + c.id) + } if (el) { items.push({ el, top: getTop(el) }) } @@ -329,6 +343,16 @@ const mapComment = ( parentUserClick: parentUserId ? () => navigateTo(`/users/${parentUserId}`) : null, }) +const mapChangeLog = (l) => ({ + id: l.id, + username: l.username, + type: l.type, + time: TimeManager.format(l.time), + newClosed: l.newClosed, + newPinnedAt: l.newPinnedAt, + newFeatured: l.newFeatured, +}) + const getTop = (el) => { return el.getBoundingClientRect().top + window.scrollY } @@ -422,19 +446,21 @@ watchEffect(() => { // router.replace('/404') // } -const totalPosts = computed(() => comments.value.length + 1) +const totalPosts = computed(() => timelineItems.value.length + 1) const lastReplyTime = computed(() => - comments.value.length ? comments.value[comments.value.length - 1].time : postTime.value, + timelineItems.value.length + ? timelineItems.value[timelineItems.value.length - 1].time + : postTime.value, ) const firstReplyTime = computed(() => - comments.value.length ? comments.value[0].time : postTime.value, + timelineItems.value.length ? timelineItems.value[0].time : postTime.value, ) const scrollerTopTime = computed(() => commentSort.value === 'OLDEST' ? postTime.value : firstReplyTime.value, ) watch( - () => comments.value.length, + () => timelineItems.value.length, async () => { await nextTick() gatherPostItems() @@ -546,6 +572,7 @@ const approvePost = async () => { status.value = 'PUBLISHED' toast.success('已通过审核') await refreshPost() + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -561,6 +588,7 @@ const pinPost = async () => { if (res.ok) { toast.success('已置顶') await refreshPost() + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -576,6 +604,7 @@ const unpinPost = async () => { if (res.ok) { toast.success('已取消置顶') await refreshPost() + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -591,6 +620,7 @@ const excludeRss = async () => { if (res.ok) { rssExcluded.value = true toast.success('已标记为rss不推荐') + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -606,6 +636,7 @@ const includeRss = async () => { if (res.ok) { rssExcluded.value = false toast.success('已标记为rss推荐') + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -622,6 +653,7 @@ const closePost = async () => { closed.value = true toast.success('已关闭') await refreshPost() + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -638,6 +670,7 @@ const reopenPost = async () => { closed.value = false toast.success('已重新打开') await refreshPost() + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -682,6 +715,7 @@ const rejectPost = async () => { status.value = 'REJECTED' toast.success('已驳回') await refreshPost() + await fetchChangeLogs() } else { toast.error('操作失败') } @@ -740,6 +774,20 @@ const fetchComments = async () => { } } +const fetchChangeLogs = async () => { + try { + const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/change-logs`) + if (res.ok) { + const data = await res.json() + changeLogs.value = data.map(mapChangeLog) + await nextTick() + gatherPostItems() + } + } catch (e) { + console.debug('Fetch change logs error', e) + } +} + watch(commentSort, fetchComments) const jumpToHashComment = async () => { @@ -763,7 +811,7 @@ const gotoProfile = () => { const initPage = async () => { scrollTo(0, 0) - await fetchComments() + await Promise.all([fetchComments(), fetchChangeLogs()]) const hash = location.hash const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null if (id) expandCommentPath(id)