feat: add paginated tag loading

This commit is contained in:
Tim
2025-09-12 17:54:28 +08:00
parent c3758cafe8
commit 5e6c8a5e87
7 changed files with 125 additions and 30 deletions

View File

@@ -80,7 +80,18 @@ public class TagController {
@ApiResponse(responseCode = "200", description = "List of tags",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))))
public List<TagDto> 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) {
if (page != null && pageSize != null) {
var tagPage = tagService.searchTags(keyword, page, pageSize);
List<Tag> tags = tagPage.getContent();
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
return tags.stream()
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
.collect(Collectors.toList());
}
List<Tag> tags = tagService.searchTags(keyword);
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);

View File

@@ -3,6 +3,7 @@ package com.openisle.repository;
import com.openisle.model.Tag;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
@@ -14,6 +15,9 @@ public interface TagRepository extends JpaRepository<Tag, Long> {
List<Tag> findByApprovedTrue();
List<Tag> findByNameContainingIgnoreCaseAndApprovedTrue(String keyword);
Page<Tag> findByApprovedTrue(Pageable pageable);
Page<Tag> findByNameContainingIgnoreCaseAndApprovedTrue(String keyword, Pageable pageable);
List<Tag> findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable);
List<Tag> findByCreator(User creator);

View File

@@ -108,6 +108,14 @@ public class TagService {
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
}
public org.springframework.data.domain.Page<Tag> searchTags(String keyword, int page, int pageSize) {
Pageable pageable = PageRequest.of(page, pageSize);
if (keyword == null || keyword.isBlank()) {
return tagRepository.findByApprovedTrue(pageable);
}
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword, pageable);
}
public List<Tag> getRecentTagsByUser(String username, int limit) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));

View File

@@ -18,6 +18,8 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import org.springframework.data.domain.PageImpl;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
@@ -86,6 +88,21 @@ class TagControllerTest {
.andExpect(jsonPath("$[0].smallIcon").value("s2"));
}
@Test
void listTagsWithPagination() throws Exception {
Tag t = new Tag();
t.setId(4L);
t.setName("tag4");
t.setDescription("d4");
t.setIcon("i4");
t.setSmallIcon("s4");
Mockito.when(tagService.searchTags(null, 0, 1)).thenReturn(new PageImpl<>(List.of(t)));
mockMvc.perform(get("/api/tags?page=0&pageSize=1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("tag4"));
}
@Test
void updateTag() throws Exception {
Tag t = new Tag();

View File

@@ -80,6 +80,12 @@
<span>{{ o.name }}</span>
</slot>
</div>
<InfiniteLoadMore
v-if="remote && hasMore"
:on-load="loadMoreOptions"
:pause="loading"
root-margin="0px"
/>
</template>
</div>
<Teleport to="body">
@@ -116,6 +122,12 @@
<span>{{ o.name }}</span>
</slot>
</div>
<InfiniteLoadMore
v-if="remote && hasMore"
:on-load="loadMoreOptions"
:pause="loading"
root-margin="0px"
/>
</template>
</div>
</div>
@@ -126,9 +138,11 @@
<script>
import { computed, onMounted, ref, watch } from 'vue'
import { useIsMobile } from '~/utils/screen'
import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
export default {
name: 'BaseDropdown',
components: { InfiniteLoadMore },
props: {
modelValue: { type: [Array, String, Number], default: () => [] },
placeholder: { type: String, default: '返回' },
@@ -152,6 +166,8 @@ export default {
const loading = ref(false)
const wrapper = ref(null)
const isMobile = useIsMobile()
const page = ref(0)
const hasMore = ref(true)
const toggle = () => {
open.value = !open.value
@@ -186,20 +202,35 @@ export default {
return options.value.filter((o) => o.name.toLowerCase().includes(search.value.toLowerCase()))
})
const loadOptions = async (kw = '') => {
const loadOptions = async (kw = '', append = false) => {
if (!props.remote && loaded.value) return
try {
loading.value = true
const res = await props.fetchOptions(props.remote ? kw : undefined)
options.value = Array.isArray(res) ? res : []
const res = await props.fetchOptions(props.remote ? kw : undefined, page.value)
const arr = Array.isArray(res) ? res : []
if (append) {
options.value = [...options.value, ...arr]
} else {
options.value = arr
}
hasMore.value = arr.length > 0
if (!append) page.value = 1
else page.value += 1
if (!props.remote) loaded.value = true
} catch {
options.value = []
if (!append) options.value = []
hasMore.value = false
} finally {
loading.value = false
}
}
const loadMoreOptions = async () => {
if (!hasMore.value) return true
await loadOptions(search.value, true)
return !hasMore.value
}
watch(
() => props.initialOptions,
(val) => {
@@ -212,6 +243,8 @@ export default {
watch(open, async (val) => {
if (val) {
if (props.remote) {
page.value = 0
hasMore.value = true
await loadOptions(search.value)
} else if (!loaded.value) {
await loadOptions()
@@ -222,6 +255,8 @@ export default {
watch(search, async (val) => {
emit('update:search', val)
if (props.remote && open.value) {
page.value = 0
hasMore.value = true
await loadOptions(val)
}
})
@@ -265,6 +300,8 @@ export default {
isImageIcon,
setSearch,
isMobile,
loadMoreOptions,
hasMore,
}
},
}

View File

@@ -115,22 +115,30 @@
<div v-if="isLoadingTag" class="menu-loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
<BaseImage
v-if="isImageIcon(t.smallIcon || t.icon)"
:src="t.smallIcon || t.icon"
class="section-item-icon"
:alt="t.name"
<div v-else>
<div v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
<BaseImage
v-if="isImageIcon(t.smallIcon || t.icon)"
:src="t.smallIcon || t.icon"
class="section-item-icon"
:alt="t.name"
/>
<component
v-else-if="t.smallIcon || t.icon"
:is="t.smallIcon || t.icon"
class="section-item-icon"
/>
<tag-one v-else class="section-item-icon" />
<span class="section-item-text">
{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span>
</span>
</div>
<InfiniteLoadMore
v-if="tagData.length"
:on-load="loadMoreTags"
:pause="isLoadingTag"
root-margin="0px"
/>
<component
v-else-if="t.smallIcon || t.icon"
:is="t.smallIcon || t.icon"
class="section-item-icon"
/>
<tag-one v-else class="section-item-icon" />
<span class="section-item-text"
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
>
</div>
</div>
</div>
@@ -154,6 +162,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()
@@ -168,6 +177,7 @@ const emit = defineEmits(['item-click'])
const categoryOpen = ref(true)
const tagOpen = ref(true)
const myPoint = ref(null)
const tagPage = ref(0)
/** ✅ 用 useAsyncData 替换原生 fetch避免 SSR+CSR 二次请求 */
const {
@@ -190,12 +200,23 @@ const {
data: tagData,
pending: isLoadingTag,
error: tagError,
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?page=0&pageSize=20`), {
server: true,
default: () => [],
staleTime: 5 * 60 * 1000,
})
const loadMoreTags = async () => {
const next = tagPage.value + 1
const res = await $fetch(`${API_BASE_URL}/api/tags?page=${next}&pageSize=20`)
if (Array.isArray(res) && res.length > 0) {
tagData.value.push(...res)
tagPage.value = next
return false
}
return true
}
/** 其余逻辑保持不变 */
const iconClass = computed(() => {
switch (themeState.mode) {

View File

@@ -62,23 +62,22 @@ const isImageIcon = (icon) => {
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
const buildTagsUrl = (kw = '') => {
const buildTagsUrl = (kw = '', page = 0) => {
const base = API_BASE_URL || (import.meta.client ? window.location.origin : '')
const url = new URL('/api/tags', base)
if (kw) url.searchParams.set('keyword', kw)
url.searchParams.set('limit', '10')
url.searchParams.set('page', String(page))
url.searchParams.set('pageSize', '20')
return url.toString()
}
const fetchTags = async (kw = '') => {
const fetchTags = async (kw = '', page = 0) => {
const defaultOption = { id: 0, name: '无标签' }
// 1) 先拼 URL自动兜底到 window.location.origin
const url = buildTagsUrl(kw)
const url = buildTagsUrl(kw, page)
// 2) 拉数据
let data = []
try {
const res = await fetch(url)
@@ -87,7 +86,6 @@ const fetchTags = async (kw = '') => {
toast.error('获取标签失败')
}
// 3) 合并、去重、可创建
let options = [...data, ...localTags.value]
if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
@@ -96,8 +94,7 @@ const fetchTags = async (kw = '') => {
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
// 4) 最终结果
return [defaultOption, ...options]
return page === 0 ? [defaultOption, ...options] : options
}
const selected = computed({