From b9a5e48d40953352e67b4b36369c2e9889f96961 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Fri, 12 Sep 2025 14:41:45 +0800 Subject: [PATCH] feat: add paginated tag loading --- .../openisle/controller/TagController.java | 11 ++-- .../openisle/repository/TagRepository.java | 3 + .../java/com/openisle/service/TagService.java | 11 ++++ .../controller/TagControllerTest.java | 4 +- frontend_nuxt/components/Dropdown.vue | 61 ++++++++++++++++--- frontend_nuxt/components/InfiniteLoadMore.vue | 15 ++++- frontend_nuxt/components/MenuComponent.vue | 42 +++++++++++-- frontend_nuxt/components/TagSelect.vue | 35 ++++++----- 8 files changed, 143 insertions(+), 39 deletions(-) diff --git a/backend/src/main/java/com/openisle/controller/TagController.java b/backend/src/main/java/com/openisle/controller/TagController.java index b5a388849..dc3978476 100644 --- a/backend/src/main/java/com/openisle/controller/TagController.java +++ b/backend/src/main/java/com/openisle/controller/TagController.java @@ -80,18 +80,15 @@ public class TagController { @ApiResponse(responseCode = "200", description = "List of tags", content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))) public List list(@RequestParam(value = "keyword", required = false) String keyword, - @RequestParam(value = "limit", required = false) Integer limit) { - List tags = tagService.searchTags(keyword); + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize) { + List tags = tagService.searchTags(keyword, page, pageSize); List tagIds = tags.stream().map(Tag::getId).toList(); Map postCntByTagIds = postService.countPostsByTagIds(tagIds); - List dtos = tags.stream() + return 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; } @GetMapping("/{id}") diff --git a/backend/src/main/java/com/openisle/repository/TagRepository.java b/backend/src/main/java/com/openisle/repository/TagRepository.java index 1e2868437..318d78a1b 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 org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; import java.util.List; import java.util.Optional; @@ -13,6 +14,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 eee84121e..dfbe9ef7e 100644 --- a/backend/src/main/java/com/openisle/service/TagService.java +++ b/backend/src/main/java/com/openisle/service/TagService.java @@ -108,6 +108,17 @@ public class TagService { return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword); } + public List searchTags(String keyword, Integer page, Integer pageSize) { + if (page == null || pageSize == null) { + return searchTags(keyword); + } + Pageable pageable = PageRequest.of(page, pageSize); + if (keyword == null || keyword.isBlank()) { + return tagRepository.findByApprovedTrue(pageable).getContent(); + } + return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword, pageable).getContent(); + } + public List getRecentTagsByUser(String username, int limit) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); diff --git a/backend/src/test/java/com/openisle/controller/TagControllerTest.java b/backend/src/test/java/com/openisle/controller/TagControllerTest.java index 69c8be30b..2d304aada 100644 --- a/backend/src/test/java/com/openisle/controller/TagControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/TagControllerTest.java @@ -75,9 +75,9 @@ class TagControllerTest { t.setDescription("d2"); t.setIcon("i2"); t.setSmallIcon("s2"); - Mockito.when(tagService.searchTags(null)).thenReturn(List.of(t)); + Mockito.when(tagService.searchTags(null, 0, 10)).thenReturn(List.of(t)); - mockMvc.perform(get("/api/tags")) + mockMvc.perform(get("/api/tags?page=0&pageSize=10")) .andExpect(status().isOk()) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].name").value("spring")) diff --git a/frontend_nuxt/components/Dropdown.vue b/frontend_nuxt/components/Dropdown.vue index 6cc680874..44e1c3fde 100644 --- a/frontend_nuxt/components/Dropdown.vue +++ b/frontend_nuxt/components/Dropdown.vue @@ -52,6 +52,7 @@ v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)" :class="['dropdown-menu', menuClass]" v-click-outside="close" + ref="menuRef" > + @@ -88,7 +96,7 @@ {{ placeholder }} - @@ -126,6 +141,7 @@ diff --git a/frontend_nuxt/components/InfiniteLoadMore.vue b/frontend_nuxt/components/InfiniteLoadMore.vue index c4e1a9e0a..2aa7ce3f5 100644 --- a/frontend_nuxt/components/InfiniteLoadMore.vue +++ b/frontend_nuxt/components/InfiniteLoadMore.vue @@ -25,6 +25,8 @@ const props = defineProps({ rootMargin: { type: String, default: '200px 0px' }, /** 触发阈值 */ threshold: { type: Number, default: 0 }, + /** 自定义 IntersectionObserver 根元素(默认为视口) */ + root: { type: Object, default: null }, }) const isLoading = ref(false) @@ -58,7 +60,11 @@ const startObserver = () => { isLoading.value = false } }, - { root: null, rootMargin: props.rootMargin, threshold: props.threshold }, + { + root: props.root || null, + rootMargin: props.rootMargin, + threshold: props.threshold, + }, ) if (sentinel.value) io.observe(sentinel.value) } @@ -76,6 +82,13 @@ watch( }, ) +watch( + () => props.root, + () => { + nextTick(startObserver) + }, +) + /** 父组件可选择性调用,用于外部强制重置(一般直接用 :key 重建更简单) */ const reset = () => { done.value = false diff --git a/frontend_nuxt/components/MenuComponent.vue b/frontend_nuxt/components/MenuComponent.vue index e7d977306..23545dc65 100644 --- a/frontend_nuxt/components/MenuComponent.vue +++ b/frontend_nuxt/components/MenuComponent.vue @@ -132,6 +132,11 @@ >{{ t.name }} x {{ t.count }} + @@ -154,6 +159,7 @@ import { authState, fetchCurrentUser } from '~/utils/auth' import { fetchUnreadCount, notificationState } from '~/utils/notification' import { useIsMobile } from '~/utils/screen' import { cycleTheme, ThemeMode, themeState } from '~/utils/theme' +import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue' const isMobile = useIsMobile() @@ -186,15 +192,41 @@ const { }, ) +const TAG_PAGE_SIZE = 10 +const tagPage = ref(1) +const tagDone = ref(false) const { data: tagData, pending: isLoadingTag, error: tagError, -} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), { - server: true, - default: () => [], - staleTime: 5 * 60 * 1000, -}) +} = await useAsyncData( + 'menu:tags', + async () => { + const res = await $fetch(`${API_BASE_URL}/api/tags?page=0&pageSize=${TAG_PAGE_SIZE}`) + if (res.length < TAG_PAGE_SIZE) tagDone.value = true + return res + }, + { + server: true, + default: () => [], + staleTime: 5 * 60 * 1000, + }, +) + +const loadMoreTags = async () => { + if (tagDone.value) return true + const res = await $fetch( + `${API_BASE_URL}/api/tags?page=${tagPage.value}&pageSize=${TAG_PAGE_SIZE}`, + ) + if (Array.isArray(res) && res.length > 0) { + tagData.value.push(...res) + if (res.length < TAG_PAGE_SIZE) tagDone.value = true + tagPage.value++ + } else { + tagDone.value = true + } + return tagDone.value +} /** 其余逻辑保持不变 */ const iconClass = computed(() => { diff --git a/frontend_nuxt/components/TagSelect.vue b/frontend_nuxt/components/TagSelect.vue index 460351434..ec709c2d2 100644 --- a/frontend_nuxt/components/TagSelect.vue +++ b/frontend_nuxt/components/TagSelect.vue @@ -6,6 +6,8 @@ placeholder="选择标签" remote :initial-options="mergedOptions" + infinite + :page-size="10" >