Compare commits

...

25 Commits

Author SHA1 Message Date
Tim
c38e4bc44c feat: enable dark mode for diff2html 2025-09-08 14:28:42 +08:00
Tim
f3421265d2 fix: 修改changelog UI 2025-09-08 14:02:47 +08:00
Tim
f4817cd6d1 Merge pull request #929 from nagisa77/codex/add-user-avatar-return-in-changelog
feat: expand post change log details
2025-09-08 13:54:51 +08:00
Tim
5ae0f9311c feat: add result change log entities 2025-09-08 13:54:35 +08:00
Tim
567452f570 feat: 标题/内容变化的ui 2025-09-08 13:46:22 +08:00
Tim
bb4e866bd0 Merge pull request #928 from nagisa77/codex/add-content-change-details-rendering
feat(frontend): render diff for content changes
2025-09-08 13:22:44 +08:00
Tim
24d0da0864 feat(frontend): render diff for content changes 2025-09-08 13:22:25 +08:00
Tim
9b53479ab6 feat: changelog前端ui优化 2025-09-08 13:04:14 +08:00
Tim
039d482517 Add post change log tracking 2025-09-08 11:27:35 +08:00
Tim
7cc32c36b1 Merge pull request #922 from nagisa77/feature/chat_ui
fix: revert 100vh 修改
2025-09-08 10:44:12 +08:00
tim
2288522372 fix: revert 100vh 修改 2025-09-08 10:43:52 +08:00
Tim
a2b72d7c00 Merge pull request #921 from nagisa77/feature/chat_ui
Chat UI update
2025-09-08 00:17:34 +08:00
Tim
a6d8add5fa Merge pull request #920 from nagisa77/codex/integrate-real-data-for-new-message-container
feat: add floating new message indicator
2025-09-07 23:57:21 +08:00
Tim
ad481cffca feat: add floating new message indicator 2025-09-07 23:57:06 +08:00
Tim
ce213d4c24 Merge pull request #918 from nagisa77/feature/menu_select_state
Some UI fixes~
2025-09-07 23:51:22 +08:00
tim
68a82fa2ec fix: 回复ui 2025-09-07 23:50:11 +08:00
tim
cab8cd06dc fix: 频道聊天,点击写个回复没反应,点击小箭头才行 #916 2025-09-07 22:46:55 +08:00
Tim
b77a96938a Merge pull request #915 from nagisa77/feature/article_ui_fix
Article UI fixes
2025-09-07 14:14:56 +08:00
Tim
df4a707e3a Merge pull request #914 from nagisa77/feature/article_ui_fix
Article UI Fixes
2025-09-07 13:58:42 +08:00
Tim
14ee5faa1f Merge pull request #913 from nagisa77/feature/sidebar-logic
fix: 更新分类选择
2025-09-07 13:38:56 +08:00
Tim
2eebc1c004 Merge pull request #911 from nagisa77/feature/sidebar-logic
feat: 侧边栏按钮样式逻辑修改
2025-09-07 13:23:05 +08:00
Tim
135a6b8c51 Merge pull request #910 from nagisa77/codex/analyze-and-refactor-case
Fix point history balance recalculation
2025-09-07 12:48:04 +08:00
Tim
c43e4b85bc Test recalculation updates balance 2025-09-07 12:47:44 +08:00
Tim
fb3a2839db Merge pull request #909 from Linindoo/main
修复纯数字用户名的用户个人首页 404 问题(注册和修改校验用户名不能为纯数字)
2025-09-07 11:14:40 +08:00
zhoujia
5534573a19 创建和更新用户名校验增加校验,不允许纯数字用户名 2025-09-05 15:08:22 +08:00
34 changed files with 1003 additions and 55 deletions

View File

@@ -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()));
}
}

View File

@@ -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<PostChangeLogDto> listLogs(@PathVariable Long id) {
return changeLogService.listLogs(id).stream()
.map(mapper::toDto)
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,31 @@
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 String userAvatar;
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;
}

View File

@@ -0,0 +1,42 @@
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());
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) {
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;
}
}

View File

@@ -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;
}

View File

@@ -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 = true)
@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;
}

View File

@@ -0,0 +1,13 @@
package com.openisle.model;
public enum PostChangeType {
CONTENT,
TITLE,
CATEGORY,
TAG,
CLOSED,
PINNED,
FEATURED,
VOTE_RESULT,
LOTTERY_RESULT
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 {
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 {
}

View File

@@ -10,6 +10,7 @@ import java.util.List;
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
List<PointHistory> findByUserOrderByIdDesc(User user);
List<PointHistory> findByUserOrderByIdAsc(User user);
long countByUser(User user);
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);

View File

@@ -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<PostChangeLog, Long> {
List<PostChangeLog> findByPostOrderByCreatedAtAsc(Post post);
}

View File

@@ -225,17 +225,20 @@ public class PointService {
*/
public int recalculateUserPoints(User user) {
// 获取用户所有的积分历史记录(由于@Where注解已删除的记录会被自动过滤
List<PointHistory> histories = pointHistoryRepository.findByUserOrderByIdDesc(user);
List<PointHistory> histories = pointHistoryRepository.findByUserOrderByIdAsc(user);
int totalPoints = 0;
for (PointHistory history : histories) {
totalPoints += history.getAmount();
// 重新计算每条历史记录的余额
history.setBalance(totalPoints);
}
// 更新用户积分
// 批量更新历史记录及用户积分
pointHistoryRepository.saveAll(histories);
user.setPoint(totalPoints);
userRepository.save(user);
return totalPoints;
}

View File

@@ -0,0 +1,108 @@
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<Tag> oldTags, Set<Tag> 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 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<PostChangeLog> listLogs(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
return logRepository.findByPostOrderByCreatedAtAsc(post);
}
}

View File

@@ -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<Long, ScheduledFuture<?>> 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<com.openisle.model.Tag> 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<com.openisle.model.Tag> newTags = new java.util.HashSet<>(tags);
if (!oldTags.equals(newTags)) {
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
}
return updated;
}

View File

@@ -1,6 +1,7 @@
package com.openisle.service;
import com.openisle.exception.FieldException;
import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.stereotype.Service;
/**
@@ -17,6 +18,11 @@ public class UsernameValidator {
if (username == null || username.isEmpty()) {
throw new FieldException("username", "Username cannot be empty");
}
if (NumberUtils.isDigits(username)) {
throw new FieldException("username", "Username cannot be pure number");
}
}
}

View File

@@ -0,0 +1,67 @@
package com.openisle.service;
import com.openisle.model.PointHistory;
import com.openisle.model.PointHistoryType;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
@DataJpaTest
@Import(PointService.class)
class PointServiceRecalculateUserPointsTest {
@Autowired
private PointService pointService;
@Autowired
private UserRepository userRepository;
@Autowired
private PointHistoryRepository pointHistoryRepository;
@Test
void recalculatesBalanceAfterDeletion() {
User user = new User();
user.setUsername("u");
user.setEmail("u@example.com");
user.setPassword("p");
user.setRole(Role.USER);
userRepository.save(user);
PointHistory h1 = new PointHistory();
h1.setUser(user);
h1.setType(PointHistoryType.POST);
h1.setAmount(30);
h1.setBalance(30);
h1.setCreatedAt(LocalDateTime.now().minusMinutes(2));
pointHistoryRepository.save(h1);
PointHistory h2 = new PointHistory();
h2.setUser(user);
h2.setType(PointHistoryType.COMMENT);
h2.setAmount(10);
h2.setBalance(40);
h2.setCreatedAt(LocalDateTime.now().minusMinutes(1));
pointHistoryRepository.save(h2);
user.setPoint(40);
userRepository.save(user);
pointHistoryRepository.delete(h1);
int total = pointService.recalculateUserPoints(user);
assertEquals(10, total);
assertEquals(10, userRepository.findById(user.getId()).orElseThrow().getPoint());
assertEquals(10, pointHistoryRepository.findById(h2.getId()).orElseThrow().getBalance());
}
}

View File

@@ -18,7 +18,9 @@
--background-color-blur: rgba(255, 255, 255, 0.57);
--menu-border-color: lightgray;
--normal-border-color: lightgray;
--menu-selected-background-color: rgba(242, 242, 242, 0.884);
--menu-selected-background-color: rgba(88, 241, 255, 0.166);
--normal-light-background-color: rgba(242, 242, 242, 0.884);
--menu-selected-background-color-hover: rgba(242, 242, 242, 0.884);
--menu-text-color: rgb(99, 99, 99);
--scroller-background-color: rgba(130, 175, 180, 0.5);
/* --normal-background-color: rgb(241, 241, 241); */
@@ -58,6 +60,8 @@
--menu-border-color: #555;
--normal-border-color: #555;
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
--normal-light-background-color: rgba(255, 255, 255, 0.1);
--menu-selected-background-color-hover: rgba(17, 182, 197, 0.082);
--menu-text-color: rgb(173, 173, 173);
/* --normal-background-color: #000000; */
--normal-background-color: #333;
@@ -162,7 +166,7 @@ body {
padding-left: 1em;
border-left: 4px solid #d0d7de;
color: var(--blockquote-text-color);
background-color: var(--menu-selected-background-color);
background-color: var(--normal-light-background-color);
padding-top: 1px;
padding-bottom: 1px;
}
@@ -295,7 +299,7 @@ body {
/* 鼠标悬停行高亮 */
.info-content-text tbody tr:hover {
background-color: var(--menu-selected-background-color);
background-color: var(--normal-light-background-color);
transition: background-color 0.2s ease;
}

View File

@@ -316,6 +316,10 @@ const gotoTag = (t) => {
align-items: center;
}
.menu-item:hover {
background-color: var(--menu-selected-background-color-hover);
}
.menu-item.selected {
font-weight: bold;
background-color: var(--menu-selected-background-color);
@@ -407,7 +411,7 @@ const gotoTag = (t) => {
}
.section-item:hover {
background-color: var(--menu-selected-background-color);
background-color: var(--menu-selected-background-color-hover);
}
.section-item-text-count {

View File

@@ -0,0 +1,127 @@
<template>
<div :id="`change-log-${log.id}`" class="change-log-container">
<div class="change-log-text">
<BaseImage
v-if="log.userAvatar"
class="change-log-avatar"
:src="log.userAvatar"
alt="avatar"
@click="() => navigateTo(`/users/${log.username}`)"
/>
<span v-if="log.username" class="change-log-user">{{ log.username }}</span>
<span v-if="log.type === 'CONTENT'" class="change-log-content">变更了文章内容</span>
<span v-else-if="log.type === 'TITLE'" class="change-log-content">变更了文章标题</span>
<span v-else-if="log.type === 'CATEGORY'" class="change-log-content">变更了文章分类</span>
<span v-else-if="log.type === 'TAG'" class="change-log-content">变更了文章标签</span>
<span v-else-if="log.type === 'CLOSED'" class="change-log-content">
<template v-if="log.newClosed">关闭了文章</template>
<template v-else>重新打开了文章</template>
</span>
<span v-else-if="log.type === 'PINNED'" class="change-log-content">
<template v-if="log.newPinnedAt">置顶了文章</template>
<template v-else>取消置顶文章</template>
</span>
<span v-else-if="log.type === 'FEATURED'" class="change-log-content">
<template v-if="log.newFeatured">将文章设为精选</template>
<template v-else>取消精选文章</template>
</span>
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content">投票已出结果</span>
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content">抽奖已开奖</span>
</div>
<div class="change-log-time">{{ log.time }}</div>
<div
v-if="log.type === 'CONTENT' || log.type === 'TITLE'"
class="content-diff"
v-html="diffHtml"
></div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { html } from 'diff2html'
import { createTwoFilesPatch } from 'diff'
import { useIsMobile } from '~/utils/screen'
import 'diff2html/bundles/css/diff2html.min.css'
import BaseImage from '~/components/BaseImage.vue'
import { navigateTo } from 'nuxt/app'
import { themeState } from '~/utils/theme'
const props = defineProps({
log: Object,
title: String,
})
const diffHtml = computed(() => {
const isMobile = useIsMobile()
// Track theme changes
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
themeState.mode
const colorScheme = isDark ? 'dark' : 'light'
if (props.log.type === 'CONTENT') {
const oldContent = props.log.oldContent ?? ''
const newContent = props.log.newContent ?? ''
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',
colorScheme,
})
} 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',
colorScheme,
})
}
return ''
})
</script>
<style scoped>
.change-log-container {
display: flex;
flex-direction: column;
/* padding-top: 5px; */
/* padding-bottom: 30px; */
font-size: 14px;
}
.change-log-text {
display: flex;
align-items: center;
}
.change-log-user {
font-weight: bold;
margin-right: 4px;
}
.change-log-user,
.change-log-content {
opacity: 0.7;
}
.change-log-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 4px;
cursor: pointer;
}
.change-log-time {
font-size: 12px;
opacity: 0.6;
}
.content-diff {
margin-top: 8px;
}
</style>

View File

@@ -136,7 +136,7 @@ export default {
left: 0;
width: 100%;
height: 100%;
background: var(--menu-selected-background-color);
background: var(--normal-light-background-color);
display: flex;
align-items: center;
justify-content: center;

View File

@@ -331,11 +331,11 @@ onMounted(async () => {
.reactions-viewer-item.placeholder,
.reactions-viewer-single-item.selected {
background-color: var(--menu-selected-background-color);
background-color: var(--normal-light-background-color);
}
.reaction-option.selected {
background-color: var(--menu-selected-background-color);
background-color: var(--normal-light-background-color);
}
@media (max-width: 768px) {

View File

@@ -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",

View File

@@ -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",

View File

@@ -65,16 +65,17 @@
class="article-item"
v-for="article in articles"
:key="article.id"
@click="navigateTo(`/posts/${article.id}`)"
>
<div class="article-main-container">
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
<NuxtLink class="article-item-title main-item">
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
<star v-if="!article.rssExcluded" class="featured-icon" />
{{ article.title }}
</NuxtLink>
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
<NuxtLink class="article-item-description main-item">
{{ sanitizeDescription(article.description) }}
</NuxtLink>
<div class="article-info-container main-item">
@@ -488,6 +489,11 @@ const sanitizeDescription = (text) => stripMarkdown(text)
border-bottom: 1px solid var(--normal-border-color);
}
.article-item:hover {
background-color: var(--menu-selected-background-color-hover);
cursor: pointer;
}
.article-main-container,
.header-item.main-item {
width: calc(60% - 20px);

View File

@@ -36,7 +36,11 @@
</div>
</div>
<div v-if="item.replyTo" class="reply-preview info-content-text">
<div class="reply-author">{{ item.replyTo.sender.username }}</div>
<div class="reply-header">
<next class="reply-icon" />
<BaseImage class="reply-avatar" :src="item.replyTo.sender.avatar" alt="avatar" />
<div class="reply-author">{{ item.replyTo.sender.username }}:</div>
</div>
<div class="reply-content" v-html="renderMarkdown(item.replyTo.content)"></div>
</div>
<div class="message-content">
@@ -48,7 +52,7 @@
:content-id="item.id"
@update:modelValue="(v) => (item.reactions = v)"
>
<div class="reply-btn"><next @click="setReply(item)" /> 写个回复...</div>
<div @click="setReply(item)" class="reply-btn"><next /> 写个回复...</div>
</ReactionsGroup>
</template>
</BaseTimeline>
@@ -63,11 +67,21 @@
</div>
<div class="message-input-area">
<div
v-if="newMessagesCount > 0 && !isUserNearBottom"
class="new-message-container"
@click="handleScrollToBottom"
>
<double-down />
<div class="new-message-count">{{ newMessagesCount }}条新消息</div>
</div>
<div v-if="replyTo" class="active-reply">
正在回复 {{ replyTo.sender.username }}:
{{ stripMarkdownLength(replyTo.content, 50) }}
<close-icon class="close-reply" @click="replyTo = null" />
</div>
<MessageEditor :loading="sending" @submit="sendMessage" />
</div>
</div>
@@ -120,6 +134,7 @@ const isChannel = ref(false)
const isFloatMode = computed(() => route.query.float !== undefined)
const floatRoute = useState('messageFloatRoute')
const replyTo = ref(null)
const newMessagesCount = ref(0)
const isUserNearBottom = ref(true)
function updateNearBottom() {
@@ -127,6 +142,9 @@ function updateNearBottom() {
if (!el) return
const threshold = 40 // px
isUserNearBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight <= threshold
if (isUserNearBottom.value) {
newMessagesCount.value = 0
}
}
const hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1)
@@ -170,6 +188,11 @@ function scrollToBottomInstant() {
el.scrollTop = el.scrollHeight
}
function handleScrollToBottom() {
scrollToBottomSmooth()
newMessagesCount.value = 0
}
async function fetchMessages(page = 0) {
if (page === 0) {
loading.value = true
@@ -301,6 +324,7 @@ async function sendMessage(content, clearInput) {
await nextTick()
// 仅“发送消息成功后”才平滑滚动到底部
scrollToBottomSmooth()
newMessagesCount.value = 0
} catch (e) {
toast.error(e.message)
} finally {
@@ -373,6 +397,8 @@ const subscribeToConversation = () => {
if (isUserNearBottom.value) {
scrollToBottomSmooth()
} else {
newMessagesCount.value += 1
}
} catch (e) {
console.error('Failed to parse websocket message', e)
@@ -555,6 +581,25 @@ function goBack() {
gap: 10px;
}
.new-message-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
cursor: pointer;
border: 1px solid var(--normal-border-color);
border-radius: 20px;
padding: 3px 6px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
width: fit-content;
position: absolute;
bottom: calc(100% + 20px);
left: 50%;
transform: translateX(-50%);
z-index: 10;
background-color: var(--background-color);
}
.user-name {
font-size: 14px;
font-weight: 600;
@@ -585,11 +630,6 @@ function goBack() {
border-bottom-left-radius: 4px;
}
.message-input-area {
margin-left: 20px;
margin-right: 20px;
}
.loading-container {
display: flex;
justify-content: center;
@@ -606,6 +646,19 @@ function goBack() {
.message-input-area {
margin-left: 10px;
margin-right: 10px;
position: relative;
}
.reply-icon {
color: var(--primary-color);
margin-right: 5px;
}
.reply-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 5px;
}
.reply-preview {
@@ -614,12 +667,19 @@ function goBack() {
border-left: 5px solid var(--primary-color);
margin-bottom: 5px;
font-size: 13px;
background-color: var(--menu-selected-background-color);
background-color: var(--normal-light-background-color);
}
.reply-header {
display: flex;
flex-direction: row;
align-items: center;
}
.reply-author {
font-weight: bold;
margin-bottom: 2px;
opacity: 0.5;
}
.reply-btn {
@@ -634,7 +694,7 @@ function goBack() {
}
.active-reply {
background-color: var(--bg-color-soft);
background-color: var(--normal-light-background-color);
padding: 5px 10px;
border-left: 5px solid var(--primary-color);
margin-bottom: 5px;

View File

@@ -419,7 +419,7 @@ function minimize() {
}
.conversation-item:hover {
background-color: var(--menu-selected-background-color);
background-color: var(--normal-light-background-color);
}
.conversation-avatar {

View File

@@ -122,9 +122,10 @@
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else class="comments-container">
<BaseTimeline :items="comments">
<BaseTimeline :items="timelineItems">
<template #item="{ item }">
<CommentItem
v-if="item.kind === 'comment'"
:key="item.id"
:comment="item"
:level="0"
@@ -133,6 +134,7 @@
:post-closed="closed"
@deleted="onCommentDeleted"
/>
<PostChangeLogItem v-else :log="item" :title="title" />
</template>
</BaseTimeline>
</div>
@@ -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,7 @@ const subscribed = ref(false)
const commentSort = ref('NEWEST')
const isFetchingComments = ref(false)
const isMobile = useIsMobile()
const timelineItems = ref([])
const headerHeight = import.meta.client
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
@@ -290,8 +295,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) })
}
@@ -323,12 +333,66 @@ 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 if (l.type === 'VOTE_RESULT') {
return 'check-one'
} else if (l.type === 'LOTTERY_RESULT') {
return 'gift'
} else {
return 'info'
}
}
const mapChangeLog = (l) => ({
id: l.id,
username: l.username,
userAvatar: l.userAvatar,
type: l.type,
createdAt: l.time,
time: TimeManager.format(l.time),
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),
})
const getTop = (el) => {
return el.getBoundingClientRect().top + window.scrollY
}
@@ -422,19 +486,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 +612,7 @@ const approvePost = async () => {
status.value = 'PUBLISHED'
toast.success('已通过审核')
await refreshPost()
await fetchChangeLogs()
} else {
toast.error('操作失败')
}
@@ -561,6 +628,7 @@ const pinPost = async () => {
if (res.ok) {
toast.success('已置顶')
await refreshPost()
await fetchChangeLogs()
} else {
toast.error('操作失败')
}
@@ -576,6 +644,7 @@ const unpinPost = async () => {
if (res.ok) {
toast.success('已取消置顶')
await refreshPost()
await fetchChangeLogs()
} else {
toast.error('操作失败')
}
@@ -591,6 +660,7 @@ const excludeRss = async () => {
if (res.ok) {
rssExcluded.value = true
toast.success('已标记为rss不推荐')
await fetchChangeLogs()
} else {
toast.error('操作失败')
}
@@ -606,6 +676,7 @@ const includeRss = async () => {
if (res.ok) {
rssExcluded.value = false
toast.success('已标记为rss推荐')
await fetchChangeLogs()
} else {
toast.error('操作失败')
}
@@ -622,6 +693,7 @@ const closePost = async () => {
closed.value = true
toast.success('已关闭')
await refreshPost()
await fetchChangeLogs()
} else {
toast.error('操作失败')
}
@@ -638,6 +710,7 @@ const reopenPost = async () => {
closed.value = false
toast.success('已重新打开')
await refreshPost()
await fetchChangeLogs()
} else {
toast.error('操作失败')
}
@@ -682,6 +755,7 @@ const rejectPost = async () => {
status.value = 'REJECTED'
toast.success('已驳回')
await refreshPost()
await fetchChangeLogs()
} else {
toast.error('操作失败')
}
@@ -740,7 +814,42 @@ const fetchComments = async () => {
}
}
watch(commentSort, fetchComments)
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)
}
}
//
// 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
@@ -763,7 +872,7 @@ const gotoProfile = () => {
const initPage = async () => {
scrollTo(0, 0)
await fetchComments()
await fetchTimeline()
const hash = location.hash
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null
if (id) expandCommentPath(id)

View File

@@ -73,6 +73,9 @@ import {
RobotOne,
Server,
Protection,
DoubleDown,
Open,
Dislike,
} from '@icon-park/vue-next'
export default defineNuxtPlugin((nuxtApp) => {
@@ -149,4 +152,7 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('RobotOne', RobotOne)
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)
})