From 6cd3cf47efb227c2468bc9bc616d57a42345441e Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:37:26 +0800 Subject: [PATCH] Add paginated tag fetching for backend and UI --- .../openisle/controller/TagController.java | 47 +++++-- .../com/openisle/dto/TagPageResponse.java | 18 +++ .../openisle/repository/TagRepository.java | 3 + .../java/com/openisle/service/TagService.java | 34 ++++- .../controller/TagControllerTest.java | 25 +++- frontend_nuxt/components/Dropdown.vue | 2 + frontend_nuxt/components/MenuComponent.vue | 130 ++++++++++++++---- frontend_nuxt/components/TagSelect.vue | 126 ++++++++++++++--- 8 files changed, 314 insertions(+), 71 deletions(-) create mode 100644 backend/src/main/java/com/openisle/dto/TagPageResponse.java diff --git a/backend/src/main/java/com/openisle/controller/TagController.java b/backend/src/main/java/com/openisle/controller/TagController.java index 51d9c54f1..20e308075 100644 --- a/backend/src/main/java/com/openisle/controller/TagController.java +++ b/backend/src/main/java/com/openisle/controller/TagController.java @@ -2,6 +2,7 @@ package com.openisle.controller; import com.openisle.dto.PostSummaryDto; import com.openisle.dto.TagDto; +import com.openisle.dto.TagPageResponse; import com.openisle.dto.TagRequest; import com.openisle.mapper.PostMapper; import com.openisle.mapper.TagMapper; @@ -19,8 +20,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.web.bind.annotation.*; @RestController @@ -96,24 +102,45 @@ public class TagController { @ApiResponse( responseCode = "200", description = "List of tags", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))) + content = @Content(schema = @Schema(implementation = TagPageResponse.class)) ) - public List list( + public TagPageResponse list( @RequestParam(value = "keyword", required = false) String keyword, - @RequestParam(value = "limit", required = false) Integer limit + @RequestParam(value = "limit", required = false) Integer limit, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize ) { - List tags = tagService.searchTags(keyword); + int resolvedPageSize = Optional.ofNullable(pageSize) + .filter(s -> s > 0) + .or(() -> Optional.ofNullable(limit).filter(l -> l > 0)) + .orElse(20); + int resolvedPage = Optional.ofNullable(page) + .filter(p -> p >= 0) + .orElse(0); + + Pageable pageable = PageRequest.of( + resolvedPage, + resolvedPageSize, + Sort.by(Sort.Direction.DESC, "createdAt") + ); + Page tagPage = tagService.searchTags(keyword, pageable); + List tags = tagPage.getContent(); List tagIds = tags.stream().map(Tag::getId).toList(); - Map postCntByTagIds = postService.countPostsByTagIds(tagIds); + Map postCntByTagIds = Optional.ofNullable( + postService.countPostsByTagIds(tagIds) + ).orElseGet(Map::of); List dtos = tags .stream() .map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L))) - .sorted((a, b) -> Long.compare(b.getCount(), a.getCount())) .collect(Collectors.toList()); - if (limit != null && limit > 0 && dtos.size() > limit) { - return dtos.subList(0, limit); - } - return dtos; + + return new TagPageResponse( + dtos, + tagPage.getNumber(), + tagPage.getSize(), + tagPage.getTotalElements(), + tagPage.hasNext() + ); } @GetMapping("/{id}") diff --git a/backend/src/main/java/com/openisle/dto/TagPageResponse.java b/backend/src/main/java/com/openisle/dto/TagPageResponse.java new file mode 100644 index 000000000..8df87f464 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/TagPageResponse.java @@ -0,0 +1,18 @@ +package com.openisle.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TagPageResponse { + + private List items; + private int page; + private int pageSize; + private long total; + private boolean hasNext; +} diff --git a/backend/src/main/java/com/openisle/repository/TagRepository.java b/backend/src/main/java/com/openisle/repository/TagRepository.java index 021cf94e9..814223509 100644 --- a/backend/src/main/java/com/openisle/repository/TagRepository.java +++ b/backend/src/main/java/com/openisle/repository/TagRepository.java @@ -4,6 +4,7 @@ import com.openisle.model.Tag; import com.openisle.model.User; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -12,6 +13,8 @@ public interface TagRepository extends JpaRepository { List findByApproved(boolean approved); List findByApprovedTrue(); List findByNameContainingIgnoreCaseAndApprovedTrue(String keyword); + Page findByApprovedTrue(Pageable pageable); + Page findByNameContainingIgnoreCaseAndApprovedTrue(String keyword, Pageable pageable); List findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable); List findByCreator(User creator); diff --git a/backend/src/main/java/com/openisle/service/TagService.java b/backend/src/main/java/com/openisle/service/TagService.java index 0000f9d84..db0a2127e 100644 --- a/backend/src/main/java/com/openisle/service/TagService.java +++ b/backend/src/main/java/com/openisle/service/TagService.java @@ -9,8 +9,11 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; @Service @@ -113,16 +116,37 @@ public class TagService { * @param keyword * @return */ + public List searchTags(String keyword) { + return searchTags(keyword, Pageable.unpaged()).getContent(); + } + @Cacheable( value = CachingConfig.TAG_CACHE_NAME, - key = "'searchTags:' + (#keyword ?: '')" //keyword为null的场合返回空 + key = "'searchTags:' + (#keyword ?: '') + ':' + (#pageable != null && #pageable.isPaged() ? #pageable.pageNumber : 0) + ':' + (#pageable != null && #pageable.isPaged() ? #pageable.pageSize : 0)" ) - public List searchTags(String keyword) { - if (keyword == null || keyword.isBlank()) { - return tagRepository.findByApprovedTrue(); + public Page searchTags(String keyword, Pageable pageable) { + Pageable effectivePageable = pageable != null ? pageable : Pageable.unpaged(); + boolean hasKeyword = keyword != null && !keyword.isBlank(); + + if (!effectivePageable.isPaged()) { + List tags = hasKeyword + ? tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword) + : tagRepository.findByApprovedTrue(); + return new PageImpl<>(tags, Pageable.unpaged(), tags.size()); } - return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword); + Pageable sortedPageable = effectivePageable; + if (effectivePageable.getSort().isUnsorted()) { + sortedPageable = PageRequest.of( + effectivePageable.getPageNumber(), + effectivePageable.getPageSize(), + Sort.by(Sort.Direction.DESC, "createdAt") + ); + } + + return hasKeyword + ? tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword, sortedPageable) + : tagRepository.findByApprovedTrue(sortedPageable); } public List getRecentTagsByUser(String username, int limit) { diff --git a/backend/src/test/java/com/openisle/controller/TagControllerTest.java b/backend/src/test/java/com/openisle/controller/TagControllerTest.java index c6a9fec13..d04c26529 100644 --- a/backend/src/test/java/com/openisle/controller/TagControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/TagControllerTest.java @@ -12,6 +12,7 @@ import com.openisle.repository.UserRepository; import com.openisle.service.PostService; import com.openisle.service.TagService; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -19,6 +20,8 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; @@ -81,16 +84,26 @@ class TagControllerTest { t.setDescription("d2"); t.setIcon("i2"); t.setSmallIcon("s2"); - Mockito.when(tagService.searchTags(null)).thenReturn(List.of(t)); + Mockito.when(tagService.searchTags(Mockito.isNull(), Mockito.any(Pageable.class))).thenAnswer( + invocation -> { + Pageable pageable = invocation.getArgument(1, Pageable.class); + return new PageImpl<>(List.of(t), pageable, 1); + } + ); + Mockito.when(postService.countPostsByTagIds(List.of(2L))).thenReturn(Map.of(2L, 3L)); mockMvc .perform(get("/api/tags")) .andExpect(status().isOk()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].name").value("spring")) - .andExpect(jsonPath("$[0].description").value("d2")) - .andExpect(jsonPath("$[0].icon").value("i2")) - .andExpect(jsonPath("$[0].smallIcon").value("s2")); + .andExpect(jsonPath("$.items[0].name").value("spring")) + .andExpect(jsonPath("$.items[0].description").value("d2")) + .andExpect(jsonPath("$.items[0].icon").value("i2")) + .andExpect(jsonPath("$.items[0].smallIcon").value("s2")) + .andExpect(jsonPath("$.items[0].count").value(3)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.pageSize").value(20)) + .andExpect(jsonPath("$.hasNext").value(false)) + .andExpect(jsonPath("$.total").value(1)); } @Test diff --git a/frontend_nuxt/components/Dropdown.vue b/frontend_nuxt/components/Dropdown.vue index 6cc680874..ebe1a8769 100644 --- a/frontend_nuxt/components/Dropdown.vue +++ b/frontend_nuxt/components/Dropdown.vue @@ -80,6 +80,7 @@ {{ o.name }} + @@ -116,6 +117,7 @@ {{ o.name }} + diff --git a/frontend_nuxt/components/MenuComponent.vue b/frontend_nuxt/components/MenuComponent.vue index e68544318..af567da8c 100644 --- a/frontend_nuxt/components/MenuComponent.vue +++ b/frontend_nuxt/components/MenuComponent.vue @@ -116,30 +116,41 @@ -
+
- - - - {{ t.name }} x {{ t.count }} + + + {{ t.name }} x {{ t.count }} +
+
+ 查看更多 + 加载中... + + @@ -157,7 +168,7 @@