Merge pull request #246 from nagisa77/codex/add-article-pinning-feature

Add post pinning feature
This commit is contained in:
Tim
2025-07-22 19:13:56 +08:00
committed by GitHub
6 changed files with 133 additions and 62 deletions

View File

@@ -58,6 +58,7 @@
<div class="article-item" v-for="article in articles" :key="article.id">
<div class="article-main-container">
<router-link class="article-item-title main-item" :to="`/posts/${article.id}`">
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
{{ article.title }}
</router-link>
<div class="article-item-description main-item">{{ sanitizeDescription(article.description) }}</div>
@@ -236,7 +237,8 @@ export default {
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
comments: (p.comments || []).length,
views: p.views,
time: TimeManager.format(p.createdAt)
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt
}))
)
if (data.length < pageSize) {
@@ -272,7 +274,8 @@ export default {
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
comments: (p.comments || []).length,
views: p.views,
time: TimeManager.format(p.createdAt)
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt
}))
)
if (data.length < pageSize) {
@@ -502,6 +505,11 @@ export default {
text-decoration: underline;
}
.pinned-icon {
margin-right: 4px;
color: var(--primary-color);
}
.article-item-description {
margin-top: 10px;
font-size: 14px;

View File

@@ -138,6 +138,7 @@ export default {
const postReactions = ref([])
const comments = ref([])
const status = ref('PUBLISHED')
const pinnedAt = ref(null)
const isWaitingFetchingPost = ref(false);
const isWaitingPostingComment = ref(false);
const postTime = ref('')
@@ -158,6 +159,13 @@ export default {
items.push({ text: '编辑文章', onClick: () => editPost() })
items.push({ text: '删除文章', color: 'red', onClick: () => deletePost() })
}
if (isAdmin.value) {
if (pinnedAt.value) {
items.push({ text: '取消置顶', onClick: () => unpinPost() })
} else {
items.push({ text: '置顶', onClick: () => pinPost() })
}
}
if (isAdmin.value && status.value === 'PENDING') {
items.push({ text: '通过审核', onClick: () => approvePost() })
items.push({ text: '驳回', color: 'red', onClick: () => rejectPost() })
@@ -277,6 +285,7 @@ export default {
comments.value = (data.comments || []).map(mapComment)
subscribed.value = !!data.subscribed
status.value = data.status
pinnedAt.value = data.pinnedAt
postTime.value = TimeManager.format(data.createdAt)
await nextTick()
gatherPostItems()
@@ -395,6 +404,36 @@ export default {
}
}
const pinPost = async () => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/pin`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
pinnedAt.value = new Date().toISOString()
toast.success('已置顶')
} else {
toast.error('操作失败')
}
}
const unpinPost = async () => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/unpin`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
pinnedAt.value = null
toast.success('已取消置顶')
} else {
toast.error('操作失败')
}
}
const editPost = () => {
router.push(`/posts/${postId}/edit`)
}
@@ -514,12 +553,15 @@ export default {
editPost,
onCommentDeleted,
deletePost,
pinPost,
unpinPost,
rejectPost,
lightboxVisible,
lightboxIndex,
lightboxImgs,
handleContentClick,
isMobile
isMobile,
pinnedAt
}
}
}

View File

@@ -36,6 +36,16 @@ public class AdminPostController {
return toDto(postService.rejectPost(id));
}
@PostMapping("/{id}/pin")
public PostDto pin(@PathVariable Long id) {
return toDto(postService.pinPost(id));
}
@PostMapping("/{id}/unpin")
public PostDto unpin(@PathVariable Long id) {
return toDto(postService.unpinPost(id));
}
private PostDto toDto(Post post) {
PostDto dto = new PostDto();
dto.setId(post.getId());
@@ -46,6 +56,7 @@ public class AdminPostController {
dto.setCategory(toCategoryDto(post.getCategory()));
dto.setViews(post.getViews());
dto.setStatus(post.getStatus());
dto.setPinnedAt(post.getPinnedAt());
return dto;
}
@@ -69,6 +80,7 @@ public class AdminPostController {
private CategoryDto category;
private long views;
private com.openisle.model.PostStatus status;
private LocalDateTime pinnedAt;
}
@Data

View File

@@ -143,6 +143,7 @@ public class PostController {
dto.setTags(post.getTags().stream().map(this::toTagDto).collect(Collectors.toList()));
dto.setViews(post.getViews());
dto.setStatus(post.getStatus());
dto.setPinnedAt(post.getPinnedAt());
List<ReactionDto> reactions = reactionService.getReactionsForPost(post.getId())
.stream()
@@ -259,6 +260,7 @@ public class PostController {
private java.util.List<TagDto> tags;
private long views;
private com.openisle.model.PostStatus status;
private LocalDateTime pinnedAt;
private List<CommentDto> comments;
private List<ReactionDto> reactions;
private java.util.List<AuthorDto> participants;

View File

@@ -59,4 +59,7 @@ public class Post {
@Column(nullable = false)
private PostStatus status = PostStatus.PUBLISHED;
@Column
private LocalDateTime pinnedAt;
}

View File

@@ -173,22 +173,14 @@ public class PostService {
java.util.List<Long> tagIds,
Integer page,
Integer pageSize) {
Pageable pageable = null;
if (page != null && pageSize != null) {
pageable = PageRequest.of(page, pageSize, Sort.Direction.DESC, "createdAt");
}
boolean hasCategories = categoryIds != null && !categoryIds.isEmpty();
boolean hasTags = tagIds != null && !tagIds.isEmpty();
if (!hasCategories && !hasTags) {
if (pageable != null) {
return postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED, pageable);
}
return postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED);
}
java.util.List<Post> posts;
if (hasCategories) {
if (!hasCategories && !hasTags) {
posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED);
} else if (hasCategories) {
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
if (categories.isEmpty()) {
return java.util.List.of();
@@ -198,49 +190,33 @@ public class PostService {
if (tags.isEmpty()) {
return java.util.List.of();
}
if (pageable != null) {
return postRepository.findByCategoriesAndAllTagsOrderByViewsDesc(
categories, tags, PostStatus.PUBLISHED, tags.size(), pageable);
}
return postRepository.findByCategoriesAndAllTagsOrderByViewsDesc(
posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc(
categories, tags, PostStatus.PUBLISHED, tags.size());
} else {
posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc(categories, PostStatus.PUBLISHED);
}
if (pageable != null) {
return postRepository.findByCategoryInAndStatusOrderByViewsDesc(categories, PostStatus.PUBLISHED, pageable);
} else {
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
if (tags.isEmpty()) {
return java.util.List.of();
}
return postRepository.findByCategoryInAndStatusOrderByViewsDesc(categories, PostStatus.PUBLISHED);
posts = postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size());
}
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
if (tags.isEmpty()) {
return java.util.List.of();
}
if (pageable != null) {
return postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size(), pageable);
}
return postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size());
return paginate(sortByPinnedAndViews(posts), page, pageSize);
}
public List<Post> listPostsByCategories(java.util.List<Long> categoryIds,
Integer page,
Integer pageSize) {
Pageable pageable = null;
if (page != null && pageSize != null) {
pageable = PageRequest.of(page, pageSize, Sort.Direction.DESC, "createdAt");
}
if (categoryIds == null || categoryIds.isEmpty()) {
if (pageable != null) {
return postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable);
}
return postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
java.util.List<Post> posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
}
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
if (pageable != null) {
return postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED, pageable);
}
return postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED);
java.util.List<Post> posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED);
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
}
public List<Post> getRecentPostsByUser(String username, int limit) {
@@ -266,20 +242,13 @@ public class PostService {
return java.util.List.of();
}
Pageable pageable = null;
if (page != null && pageSize != null) {
pageable = PageRequest.of(page, pageSize, Sort.Direction.DESC, "createdAt");
}
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
if (tags.isEmpty()) {
return java.util.List.of();
}
if (pageable != null) {
return postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size(), pageable);
}
return postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size());
java.util.List<Post> posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size());
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
}
public List<Post> listPostsByCategoriesAndTags(java.util.List<Long> categoryIds,
@@ -290,21 +259,14 @@ public class PostService {
return java.util.List.of();
}
Pageable pageable = null;
if (page != null && pageSize != null) {
pageable = PageRequest.of(page, pageSize);
}
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
if (categories.isEmpty() || tags.isEmpty()) {
return java.util.List.of();
}
if (pageable != null) {
return postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(categories, tags, PostStatus.PUBLISHED, tags.size(), pageable);
}
return postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(categories, tags, PostStatus.PUBLISHED, tags.size());
java.util.List<Post> posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(categories, tags, PostStatus.PUBLISHED, tags.size());
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
}
public List<Post> listPendingPosts() {
@@ -347,6 +309,20 @@ public class PostService {
return post;
}
public Post pinPost(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
post.setPinnedAt(java.time.LocalDateTime.now());
return postRepository.save(post);
}
public Post unpinPost(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
post.setPinnedAt(null);
return postRepository.save(post);
}
@org.springframework.transaction.annotation.Transactional
public Post updatePost(Long id,
String username,
@@ -410,4 +386,32 @@ public class PostService {
public long countPostsByTag(Long tagId) {
return postRepository.countDistinctByTags_Id(tagId);
}
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
return posts.stream()
.sorted(java.util.Comparator
.comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))
.thenComparing(Post::getCreatedAt, java.util.Comparator.reverseOrder()))
.toList();
}
private java.util.List<Post> sortByPinnedAndViews(java.util.List<Post> posts) {
return posts.stream()
.sorted(java.util.Comparator
.comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))
.thenComparing(Post::getViews, java.util.Comparator.reverseOrder()))
.toList();
}
private java.util.List<Post> paginate(java.util.List<Post> posts, Integer page, Integer pageSize) {
if (page == null || pageSize == null) {
return posts;
}
int from = page * pageSize;
if (from >= posts.size()) {
return java.util.List.of();
}
int to = Math.min(from + pageSize, posts.size());
return posts.subList(from, to);
}
}