diff --git a/backend/src/main/java/com/openisle/controller/TagController.java b/backend/src/main/java/com/openisle/controller/TagController.java index 51d9c54f1..418245c52 100644 --- a/backend/src/main/java/com/openisle/controller/TagController.java +++ b/backend/src/main/java/com/openisle/controller/TagController.java @@ -100,18 +100,31 @@ public class TagController { ) public List list( @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize, @RequestParam(value = "limit", required = false) Integer limit ) { List tags = tagService.searchTags(keyword); List tagIds = tags.stream().map(Tag::getId).toList(); Map postCntByTagIds = postService.countPostsByTagIds(tagIds); + if (postCntByTagIds == null) { + postCntByTagIds = java.util.Collections.emptyMap(); + } 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 (page != null && pageSize != null && page >= 0 && pageSize > 0) { + int fromIndex = page * pageSize; + if (fromIndex >= dtos.size()) { + return java.util.Collections.emptyList(); + } + int toIndex = Math.min(fromIndex + pageSize, dtos.size()); + return new java.util.ArrayList<>(dtos.subList(fromIndex, toIndex)); + } if (limit != null && limit > 0 && dtos.size() > limit) { - return dtos.subList(0, limit); + return new java.util.ArrayList<>(dtos.subList(0, limit)); } return dtos; } diff --git a/backend/src/test/java/com/openisle/controller/TagControllerTest.java b/backend/src/test/java/com/openisle/controller/TagControllerTest.java index c6a9fec13..d588e4f6f 100644 --- a/backend/src/test/java/com/openisle/controller/TagControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/TagControllerTest.java @@ -82,6 +82,7 @@ class TagControllerTest { t.setIcon("i2"); t.setSmallIcon("s2"); Mockito.when(tagService.searchTags(null)).thenReturn(List.of(t)); + Mockito.when(postService.countPostsByTagIds(List.of(2L))).thenReturn(java.util.Map.of()); mockMvc .perform(get("/api/tags")) @@ -93,6 +94,31 @@ class TagControllerTest { .andExpect(jsonPath("$[0].smallIcon").value("s2")); } + @Test + void listTagsWithPagination() throws Exception { + Tag t1 = new Tag(); + t1.setId(1L); + t1.setName("java"); + Tag t2 = new Tag(); + t2.setId(2L); + t2.setName("spring"); + Mockito.when(tagService.searchTags(null)).thenReturn(List.of(t1, t2)); + Mockito.when(postService.countPostsByTagIds(List.of(1L, 2L))).thenReturn( + java.util.Map.of(1L, 1L, 2L, 5L) + ); + + mockMvc + .perform(get("/api/tags").param("page", "1").param("pageSize", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", org.hamcrest.Matchers.hasSize(1))) + .andExpect(jsonPath("$[0].id").value(1)); + + mockMvc + .perform(get("/api/tags").param("page", "2").param("pageSize", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", org.hamcrest.Matchers.hasSize(0))); + } + @Test void updateTag() throws Exception { Tag t = new Tag(); diff --git a/frontend_nuxt/components/Dropdown.vue b/frontend_nuxt/components/Dropdown.vue index 6cc680874..eb41f177b 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 }} + @@ -200,6 +202,10 @@ export default { } } + const reload = async () => { + await loadOptions(props.remote ? search.value : undefined) + } + watch( () => props.initialOptions, (val) => { @@ -249,7 +255,7 @@ export default { return /^https?:\/\//.test(icon) || icon.startsWith('/') } - expose({ toggle, close }) + expose({ toggle, close, reload }) return { open, diff --git a/frontend_nuxt/components/MenuComponent.vue b/frontend_nuxt/components/MenuComponent.vue index e68544318..871eb1675 100644 --- a/frontend_nuxt/components/MenuComponent.vue +++ b/frontend_nuxt/components/MenuComponent.vue @@ -116,30 +116,42 @@ -
+
- - - - {{ t.name }} x {{ t.count }} -
+ + + + {{ t.name }} x {{ t.count }} +
+
+ + 查看更多 + + 加载中... +
+ @@ -207,16 +219,88 @@ const { }, ) +const TAG_PAGE_SIZE = 10 +const tagPage = ref(0) +const hasMoreTags = ref(true) +const isLoadingMoreTags = ref(false) + +const buildTagUrl = (page = 0) => { + const base = API_BASE_URL || (import.meta.client ? window.location.origin : '') + const url = new URL('/api/tags', base) + url.searchParams.set('page', String(page)) + url.searchParams.set('pageSize', String(TAG_PAGE_SIZE)) + return url.toString() +} + +const fetchTagPage = async (page = 0) => { + try { + return await $fetch(buildTagUrl(page)) + } catch (e) { + console.error('Failed to fetch tags', e) + return [] + } +} + const { data: tagData, pending: isLoadingTag, error: tagError, -} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), { +} = await useAsyncData('menu:tags', () => fetchTagPage(0), { server: true, default: () => [], staleTime: 5 * 60 * 1000, }) +const dedupeTags = (list) => Array.from(new Map(list.map((tag) => [tag.id, tag])).values()) + +const initializeTagState = (val) => { + const initial = Array.isArray(val) ? val : [] + if (!Array.isArray(val)) { + tagData.value = [] + } + tagPage.value = 0 + hasMoreTags.value = initial.length === TAG_PAGE_SIZE +} + +initializeTagState(tagData.value) + +watch( + tagData, + (val, oldVal) => { + const next = Array.isArray(val) ? val : [] + if (!Array.isArray(val)) { + tagData.value = [] + } + const shouldReset = + !Array.isArray(oldVal) || oldVal.length > next.length || next.length <= TAG_PAGE_SIZE + if (shouldReset) { + tagPage.value = 0 + hasMoreTags.value = next.length === TAG_PAGE_SIZE + } + }, + { deep: false }, +) + +const loadMoreTags = async () => { + if (isLoadingMoreTags.value || !hasMoreTags.value) return + isLoadingMoreTags.value = true + const nextPage = tagPage.value + 1 + try { + const result = await fetchTagPage(nextPage) + const data = Array.isArray(result) ? result : [] + const existing = Array.isArray(tagData.value) ? tagData.value : [] + tagData.value = dedupeTags([...existing, ...data]) + tagPage.value = nextPage + if (data.length < TAG_PAGE_SIZE) { + hasMoreTags.value = false + } + } catch (e) { + console.error('Failed to load more tags', e) + } finally { + isLoadingMoreTags.value = false + } +} + /** 其余逻辑保持不变 */ const iconClass = computed(() => { switch (themeState.mode) { @@ -433,6 +517,27 @@ const gotoTag = (t) => { transition: background-color 0.5s ease; } +.more-item { + justify-content: center; +} + +.more-link { + color: var(--primary-color); + text-decoration: none; + font-size: 14px; + cursor: pointer; +} + +.more-link:hover { + text-decoration: underline; +} + +.more-loading { + font-size: 13px; + color: var(--menu-text-color); + opacity: 0.7; +} + .section-item:hover { background-color: var(--menu-selected-background-color-hover); } @@ -441,7 +546,6 @@ const gotoTag = (t) => { background-color: var(--menu-selected-background-color); } - .section-item-text-count { font-size: 12px; color: var(--menu-text-color); diff --git a/frontend_nuxt/components/TagSelect.vue b/frontend_nuxt/components/TagSelect.vue index 460351434..4864814eb 100644 --- a/frontend_nuxt/components/TagSelect.vue +++ b/frontend_nuxt/components/TagSelect.vue @@ -1,5 +1,6 @@ +