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 01/15] 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) From 9b53479ab6bc4370173d74cf7a49da94898a2536 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 8 Sep 2025 13:04:14 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20changelog=E5=89=8D=E7=AB=AFui?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/PostChangeLogItem.vue | 3 +- frontend_nuxt/pages/posts/[id]/index.vue | 64 ++++++++++++++++--- frontend_nuxt/plugins/iconpark.client.ts | 4 ++ 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/frontend_nuxt/components/PostChangeLogItem.vue b/frontend_nuxt/components/PostChangeLogItem.vue index d962171b6..6c0b6237c 100644 --- a/frontend_nuxt/components/PostChangeLogItem.vue +++ b/frontend_nuxt/components/PostChangeLogItem.vue @@ -31,7 +31,8 @@ const props = defineProps({ log: Object }) .change-log-container { display: flex; flex-direction: column; - padding: 4px 0; + padding-bottom: 30px; + opacity: 0.7; } .change-log-user { font-weight: bold; diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 3ddb9c7ad..d2bb041bf 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -134,7 +134,7 @@ :post-closed="closed" @deleted="onCommentDeleted" /> - + @@ -229,11 +229,7 @@ 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 timelineItems = ref([]) const headerHeight = import.meta.client ? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0 @@ -337,20 +333,51 @@ const mapComment = ( ), openReplies: level === 0, src: c.author.avatar, + createdAt: c.createdAt, iconClick: () => navigateTo(`/users/${c.author.id}`), parentUserName: parentUserName, parentUserAvatar: parentUserAvatar, parentUserClick: parentUserId ? () => navigateTo(`/users/${parentUserId}`) : null, }) +const changeLogIcon = (l) => { + if (l.type === 'CONTENT') { + return 'edit' + } else if (l.type === 'TITLE') { + return 'hashtag-key' + } else if (l.type === 'CATEGORY') { + return 'tag-one' + } else if (l.type === 'TAG') { + return 'tag-one' + } else if (l.type === 'CLOSED') { + if (l.newClosed) { + return 'lock-one' + } else { + return 'unlock' + } + } else if (l.type === 'PINNED') { + return 'pin-icon' + } else if (l.type === 'FEATURED') { + if (l.newFeatured) { + return 'star' + } else { + return 'dislike' + } + } else { + return 'info' + } +} + const mapChangeLog = (l) => ({ id: l.id, username: l.username, type: l.type, + createdAt: l.time, time: TimeManager.format(l.time), newClosed: l.newClosed, newPinnedAt: l.newPinnedAt, newFeatured: l.newFeatured, + icon: changeLogIcon(l), }) const getTop = (el) => { @@ -788,7 +815,28 @@ const fetchChangeLogs = async () => { } } -watch(commentSort, fetchComments) +// +// todo(tim): fetchComments, fetchChangeLogs 整合到一个请求,并且取消前端排序 +// +const fetchTimeline = async () => { + await Promise.all([fetchComments(), fetchChangeLogs()]) + const cs = comments.value.map((c) => ({ ...c, kind: 'comment' })) + const ls = changeLogs.value.map((l) => ({ ...l, kind: 'log' })) + + if (commentSort.value === 'NEWEST') { + timelineItems.value = [...cs, ...ls].sort( + (a, b) => new Date(b.createdAt) - new Date(a.createdAt), + ) + } else { + timelineItems.value = [...cs, ...ls].sort( + (a, b) => new Date(a.createdAt) - new Date(b.createdAt), + ) + } +} + +watch(commentSort, async () => { + await fetchTimeline() +}) const jumpToHashComment = async () => { const hash = location.hash @@ -811,7 +859,7 @@ const gotoProfile = () => { const initPage = async () => { scrollTo(0, 0) - await Promise.all([fetchComments(), fetchChangeLogs()]) + await fetchTimeline() const hash = location.hash const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null if (id) expandCommentPath(id) diff --git a/frontend_nuxt/plugins/iconpark.client.ts b/frontend_nuxt/plugins/iconpark.client.ts index ab87cc6bf..643b8b21f 100644 --- a/frontend_nuxt/plugins/iconpark.client.ts +++ b/frontend_nuxt/plugins/iconpark.client.ts @@ -74,6 +74,8 @@ import { Server, Protection, DoubleDown, + Open, + Dislike, } from '@icon-park/vue-next' export default defineNuxtPlugin((nuxtApp) => { @@ -151,4 +153,6 @@ export default defineNuxtPlugin((nuxtApp) => { nuxtApp.vueApp.component('ServerIcon', Server) nuxtApp.vueApp.component('Protection', Protection) nuxtApp.vueApp.component('DoubleDown', DoubleDown) + nuxtApp.vueApp.component('OpenIcon', Open) + nuxtApp.vueApp.component('Dislike', Dislike) }) From 24d0da08645ec6abd1c6926efe3ec3b874689636 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 8 Sep 2025 13:22:25 +0800 Subject: [PATCH 03/15] feat(frontend): render diff for content changes --- .../components/PostChangeLogItem.vue | 22 ++++- frontend_nuxt/package-lock.json | 80 +++++++++++++++++++ frontend_nuxt/package.json | 2 + 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/frontend_nuxt/components/PostChangeLogItem.vue b/frontend_nuxt/components/PostChangeLogItem.vue index 6c0b6237c..3005e5c3b 100644 --- a/frontend_nuxt/components/PostChangeLogItem.vue +++ b/frontend_nuxt/components/PostChangeLogItem.vue @@ -2,7 +2,10 @@
{{ log.username }} - 变更了文章内容 + + 变更了文章内容 +
+
变更了文章标题 变更了文章分类 变更了文章标签 @@ -24,7 +27,20 @@ diff --git a/frontend_nuxt/package-lock.json b/frontend_nuxt/package-lock.json index 1e9ca8f91..4c8d81069 100644 --- a/frontend_nuxt/package-lock.json +++ b/frontend_nuxt/package-lock.json @@ -10,6 +10,8 @@ "@nuxt/image": "^1.11.0", "@stomp/stompjs": "^7.0.0", "cropperjs": "^1.6.2", + "diff": "^8.0.2", + "diff2html": "^3.4.52", "echarts": "^5.6.0", "flatpickr": "^4.6.13", "highlight.js": "^11.11.1", @@ -7218,6 +7220,41 @@ "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", "license": "Apache-2.0" }, + "node_modules/diff2html": { + "version": "3.4.52", + "resolved": "https://registry.npmjs.org/diff2html/-/diff2html-3.4.52.tgz", + "integrity": "sha512-qhMg8/I3sZ4zm/6R/Kh0xd6qG6Vm86w6M+C9W+DuH1V8ACz+1cgEC8/k0ucjv6AGqZWzHm/8G1gh7IlrUqCMhg==", + "license": "MIT", + "dependencies": { + "diff": "^7.0.0", + "hogan.js": "3.0.2" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "highlight.js": "11.9.0" + } + }, + "node_modules/diff2html/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff2html/node_modules/highlight.js": { + "version": "11.9.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz", + "integrity": "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -8291,6 +8328,49 @@ "node": ">=12.0.0" } }, + "node_modules/hogan.js": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/hogan.js/-/hogan.js-3.0.2.tgz", + "integrity": "sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==", + "dependencies": { + "mkdirp": "0.3.0", + "nopt": "1.0.10" + }, + "bin": { + "hulk": "bin/hulk" + } + }, + "node_modules/hogan.js/node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/hogan.js/node_modules/mkdirp": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz", + "integrity": "sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew==", + "deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/hogan.js/node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "license": "MIT", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", diff --git a/frontend_nuxt/package.json b/frontend_nuxt/package.json index 8df2c955a..65385285c 100644 --- a/frontend_nuxt/package.json +++ b/frontend_nuxt/package.json @@ -16,6 +16,8 @@ "@nuxt/image": "^1.11.0", "@stomp/stompjs": "^7.0.0", "cropperjs": "^1.6.2", + "diff": "^8.0.2", + "diff2html": "^3.4.52", "echarts": "^5.6.0", "flatpickr": "^4.6.13", "highlight.js": "^11.11.1", From 567452f570b047b1c170ff33dd5c6c0a6addcbbb Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 8 Sep 2025 13:46:22 +0800 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=E6=A0=87=E9=A2=98/=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=8F=98=E5=8C=96=E7=9A=84ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/PostChangeLogItem.vue | 42 +++++++++++++++---- frontend_nuxt/pages/posts/[id]/index.vue | 10 ++++- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/frontend_nuxt/components/PostChangeLogItem.vue b/frontend_nuxt/components/PostChangeLogItem.vue index 3005e5c3b..e5f970054 100644 --- a/frontend_nuxt/components/PostChangeLogItem.vue +++ b/frontend_nuxt/components/PostChangeLogItem.vue @@ -2,10 +2,7 @@
{{ log.username }} - - 变更了文章内容 -
-
+ 变更了文章内容 变更了文章标题 变更了文章分类 变更了文章标签 @@ -23,6 +20,11 @@
{{ log.time }}
+
@@ -30,14 +32,37 @@ import { computed } from 'vue' import { html } from 'diff2html' import { createTwoFilesPatch } from 'diff' +import { useIsMobile } from '~/utils/screen' import 'diff2html/bundles/css/diff2html.min.css' -const props = defineProps({ log: Object }) +const props = defineProps({ + log: Object, + title: String, +}) + const diffHtml = computed(() => { + const isMobile = useIsMobile() if (props.log.type === 'CONTENT') { const oldContent = props.log.oldContent ?? '' const newContent = props.log.newContent ?? '' - const diff = createTwoFilesPatch('old', 'new', oldContent, newContent) - return html(diff, { inputFormat: 'diff', showFiles: false, matching: 'lines' }) + const diff = createTwoFilesPatch(props.title, props.title, oldContent, newContent) + return html(diff, { + inputFormat: 'diff', + showFiles: false, + matching: 'lines', + drawFileList: false, + outputFormat: isMobile.value ? 'line-by-line' : 'side-by-side', + }) + } else if (props.log.type === 'TITLE') { + const oldTitle = props.log.oldTitle ?? '' + const newTitle = props.log.newTitle ?? '' + const diff = createTwoFilesPatch(oldTitle, newTitle, '', '') + return html(diff, { + inputFormat: 'diff', + showFiles: false, + matching: 'lines', + drawFileList: false, + outputFormat: isMobile.value ? 'line-by-line' : 'side-by-side', + }) } return '' }) @@ -47,8 +72,9 @@ const diffHtml = computed(() => { .change-log-container { display: flex; flex-direction: column; - padding-bottom: 30px; + /* padding-bottom: 30px; */ opacity: 0.7; + font-size: 14px; } .change-log-user { font-weight: bold; diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index d2bb041bf..218ef8779 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -134,7 +134,7 @@ :post-closed="closed" @deleted="onCommentDeleted" /> - +
@@ -377,6 +377,14 @@ const mapChangeLog = (l) => ({ newClosed: l.newClosed, newPinnedAt: l.newPinnedAt, newFeatured: l.newFeatured, + oldContent: l.oldContent, + newContent: l.newContent, + oldTitle: l.oldTitle, + newTitle: l.newTitle, + oldCategory: l.oldCategory, + newCategory: l.newCategory, + oldTags: l.oldTags, + newTags: l.newTags, icon: changeLogIcon(l), }) From 5ae0f9311cfedc13aaf04a4c647f3d441bf356c6 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 8 Sep 2025 13:54:35 +0800 Subject: [PATCH 05/15] feat: add result change log entities --- .../com/openisle/dto/PostChangeLogDto.java | 1 + .../openisle/mapper/PostChangeLogMapper.java | 5 ++++- .../com/openisle/model/PostChangeLog.java | 2 +- .../com/openisle/model/PostChangeType.java | 4 +++- .../model/PostLotteryResultChangeLog.java | 16 ++++++++++++++ .../model/PostVoteResultChangeLog.java | 16 ++++++++++++++ .../service/PostChangeLogService.java | 14 +++++++++++++ .../components/PostChangeLogItem.vue | 21 ++++++++++++++++++- frontend_nuxt/pages/posts/[id]/index.vue | 5 +++++ 9 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/openisle/model/PostLotteryResultChangeLog.java create mode 100644 backend/src/main/java/com/openisle/model/PostVoteResultChangeLog.java diff --git a/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java b/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java index 57335a56f..502dea6aa 100644 --- a/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java +++ b/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java @@ -11,6 +11,7 @@ import java.time.LocalDateTime; public class PostChangeLogDto { private Long id; private String username; + private String userAvatar; private PostChangeType type; private LocalDateTime time; private String oldTitle; diff --git a/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java b/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java index 025793986..bdc46c079 100644 --- a/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java @@ -9,7 +9,10 @@ public class PostChangeLogMapper { public PostChangeLogDto toDto(PostChangeLog log) { PostChangeLogDto dto = new PostChangeLogDto(); dto.setId(log.getId()); - dto.setUsername(log.getUser().getUsername()); + if (log.getUser() != null) { + dto.setUsername(log.getUser().getUsername()); + dto.setUserAvatar(log.getUser().getAvatar()); + } dto.setType(log.getType()); dto.setTime(log.getCreatedAt()); if (log instanceof PostTitleChangeLog t) { diff --git a/backend/src/main/java/com/openisle/model/PostChangeLog.java b/backend/src/main/java/com/openisle/model/PostChangeLog.java index d577fdbf9..a30cd3b00 100644 --- a/backend/src/main/java/com/openisle/model/PostChangeLog.java +++ b/backend/src/main/java/com/openisle/model/PostChangeLog.java @@ -23,7 +23,7 @@ public abstract class PostChangeLog { @JoinColumn(name = "post_id") private Post post; - @ManyToOne(fetch = FetchType.LAZY, optional = false) + @ManyToOne(fetch = FetchType.LAZY, optional = true) @JoinColumn(name = "user_id") private User user; diff --git a/backend/src/main/java/com/openisle/model/PostChangeType.java b/backend/src/main/java/com/openisle/model/PostChangeType.java index 5cff69910..68b869f7f 100644 --- a/backend/src/main/java/com/openisle/model/PostChangeType.java +++ b/backend/src/main/java/com/openisle/model/PostChangeType.java @@ -7,5 +7,7 @@ public enum PostChangeType { TAG, CLOSED, PINNED, - FEATURED + FEATURED, + VOTE_RESULT, + LOTTERY_RESULT } diff --git a/backend/src/main/java/com/openisle/model/PostLotteryResultChangeLog.java b/backend/src/main/java/com/openisle/model/PostLotteryResultChangeLog.java new file mode 100644 index 000000000..138d9d9cb --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostLotteryResultChangeLog.java @@ -0,0 +1,16 @@ +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_lottery_result_change_logs") +public class PostLotteryResultChangeLog extends PostChangeLog { +} + diff --git a/backend/src/main/java/com/openisle/model/PostVoteResultChangeLog.java b/backend/src/main/java/com/openisle/model/PostVoteResultChangeLog.java new file mode 100644 index 000000000..32a61c210 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostVoteResultChangeLog.java @@ -0,0 +1,16 @@ +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_vote_result_change_logs") +public class PostVoteResultChangeLog extends PostChangeLog { +} + diff --git a/backend/src/main/java/com/openisle/service/PostChangeLogService.java b/backend/src/main/java/com/openisle/service/PostChangeLogService.java index b88f43de2..7244c4ca8 100644 --- a/backend/src/main/java/com/openisle/service/PostChangeLogService.java +++ b/backend/src/main/java/com/openisle/service/PostChangeLogService.java @@ -86,6 +86,20 @@ public class PostChangeLogService { logRepository.save(log); } + public void recordVoteResult(Post post) { + PostVoteResultChangeLog log = new PostVoteResultChangeLog(); + log.setPost(post); + log.setType(PostChangeType.VOTE_RESULT); + logRepository.save(log); + } + + public void recordLotteryResult(Post post) { + PostLotteryResultChangeLog log = new PostLotteryResultChangeLog(); + log.setPost(post); + log.setType(PostChangeType.LOTTERY_RESULT); + logRepository.save(log); + } + public List listLogs(Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); diff --git a/frontend_nuxt/components/PostChangeLogItem.vue b/frontend_nuxt/components/PostChangeLogItem.vue index e5f970054..96db77e06 100644 --- a/frontend_nuxt/components/PostChangeLogItem.vue +++ b/frontend_nuxt/components/PostChangeLogItem.vue @@ -1,7 +1,13 @@