mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-25 15:40:49 +08:00
feat: add paginated tag loading
This commit is contained in:
@@ -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<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
List<Tag> tags = tagService.searchTags(keyword);
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||
List<Tag> tags = tagService.searchTags(keyword, page, pageSize);
|
||||
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
||||
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
|
||||
List<TagDto> 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}")
|
||||
|
||||
@@ -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<Tag, Long> {
|
||||
List<Tag> findByApproved(boolean approved);
|
||||
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);
|
||||
|
||||
@@ -108,6 +108,17 @@ public class TagService {
|
||||
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
|
||||
}
|
||||
|
||||
public List<Tag> 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<Tag> getRecentTagsByUser(String username, int limit) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)"
|
||||
:class="['dropdown-menu', menuClass]"
|
||||
v-click-outside="close"
|
||||
ref="menuRef"
|
||||
>
|
||||
<div v-if="showSearch" class="dropdown-search">
|
||||
<search-icon class="search-icon" />
|
||||
@@ -80,6 +81,13 @@
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
<InfiniteLoadMore
|
||||
v-if="infinite && !done"
|
||||
:on-load="fetchNextPage"
|
||||
:pause="loading"
|
||||
:root="menuRef"
|
||||
root-margin="0px 0px"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
@@ -88,7 +96,7 @@
|
||||
<next class="back-icon" @click="close" />
|
||||
<span class="mobile-title">{{ placeholder }}</span>
|
||||
</div>
|
||||
<div class="dropdown-mobile-menu">
|
||||
<div class="dropdown-mobile-menu" ref="mobileMenuRef">
|
||||
<div v-if="showSearch" class="dropdown-search">
|
||||
<search-icon class="search-icon" />
|
||||
<input type="text" v-model="search" placeholder="搜索" />
|
||||
@@ -116,6 +124,13 @@
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
<InfiniteLoadMore
|
||||
v-if="infinite && !done"
|
||||
:on-load="fetchNextPage"
|
||||
:pause="loading"
|
||||
:root="mobileMenuRef"
|
||||
root-margin="0px 0px"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,6 +141,7 @@
|
||||
<script>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
|
||||
|
||||
export default {
|
||||
name: 'BaseDropdown',
|
||||
@@ -139,6 +155,8 @@ export default {
|
||||
optionClass: { type: String, default: '' },
|
||||
showSearch: { type: Boolean, default: true },
|
||||
initialOptions: { type: Array, default: () => [] },
|
||||
infinite: { type: Boolean, default: false },
|
||||
pageSize: { type: Number, default: 20 },
|
||||
},
|
||||
emits: ['update:modelValue', 'update:search', 'close'],
|
||||
setup(props, { emit, expose }) {
|
||||
@@ -152,6 +170,10 @@ export default {
|
||||
const loading = ref(false)
|
||||
const wrapper = ref(null)
|
||||
const isMobile = useIsMobile()
|
||||
const menuRef = ref(null)
|
||||
const mobileMenuRef = ref(null)
|
||||
const page = ref(0)
|
||||
const done = ref(false)
|
||||
|
||||
const toggle = () => {
|
||||
open.value = !open.value
|
||||
@@ -188,18 +210,36 @@ export default {
|
||||
|
||||
const loadOptions = async (kw = '') => {
|
||||
if (!props.remote && loaded.value) return
|
||||
if (done.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 : []
|
||||
arr.forEach((o) => {
|
||||
if (!options.value.some((e) => e.id === o.id)) {
|
||||
options.value.push(o)
|
||||
}
|
||||
})
|
||||
if (arr.length < props.pageSize) done.value = true
|
||||
if (!props.remote) loaded.value = true
|
||||
} catch {
|
||||
options.value = []
|
||||
page.value += 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetAndLoad = async (kw = '') => {
|
||||
options.value = Array.isArray(props.initialOptions) ? [...props.initialOptions] : []
|
||||
page.value = 0
|
||||
done.value = false
|
||||
await loadOptions(kw)
|
||||
}
|
||||
|
||||
const fetchNextPage = async () => {
|
||||
await loadOptions(search.value)
|
||||
return done.value
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initialOptions,
|
||||
(val) => {
|
||||
@@ -212,7 +252,7 @@ export default {
|
||||
watch(open, async (val) => {
|
||||
if (val) {
|
||||
if (props.remote) {
|
||||
await loadOptions(search.value)
|
||||
await resetAndLoad(search.value)
|
||||
} else if (!loaded.value) {
|
||||
await loadOptions()
|
||||
}
|
||||
@@ -222,13 +262,13 @@ export default {
|
||||
watch(search, async (val) => {
|
||||
emit('update:search', val)
|
||||
if (props.remote && open.value) {
|
||||
await loadOptions(val)
|
||||
await resetAndLoad(val)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.remote) {
|
||||
loadOptions()
|
||||
resetAndLoad()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -259,14 +299,19 @@ export default {
|
||||
search,
|
||||
filteredOptions,
|
||||
wrapper,
|
||||
menuRef,
|
||||
mobileMenuRef,
|
||||
selectedLabels,
|
||||
isSelected,
|
||||
loading,
|
||||
isImageIcon,
|
||||
fetchNextPage,
|
||||
done,
|
||||
setSearch,
|
||||
isMobile,
|
||||
}
|
||||
},
|
||||
components: { InfiniteLoadMore },
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -132,6 +132,11 @@
|
||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||
>
|
||||
</div>
|
||||
<InfiniteLoadMore
|
||||
v-if="tagData.length > 0 && !tagDone"
|
||||
:on-load="loadMoreTags"
|
||||
:pause="isLoadingTag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
placeholder="选择标签"
|
||||
remote
|
||||
:initial-options="mergedOptions"
|
||||
infinite
|
||||
:page-size="10"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="option-container">
|
||||
@@ -42,6 +44,7 @@ const props = defineProps({
|
||||
options: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const defaultOption = { id: 0, name: '无标签' }
|
||||
const localTags = ref([])
|
||||
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||
|
||||
@@ -53,7 +56,7 @@ watch(
|
||||
)
|
||||
|
||||
const mergedOptions = computed(() => {
|
||||
const arr = [...providedTags.value, ...localTags.value]
|
||||
const arr = [defaultOption, ...providedTags.value, ...localTags.value]
|
||||
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
|
||||
})
|
||||
|
||||
@@ -62,21 +65,20 @@ 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', '10')
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const fetchTags = async (kw = '') => {
|
||||
const defaultOption = { id: 0, name: '无标签' }
|
||||
|
||||
const fetchTags = async (kw = '', page = 0) => {
|
||||
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
||||
const url = buildTagsUrl(kw)
|
||||
const url = buildTagsUrl(kw, page)
|
||||
|
||||
// 2) 拉数据
|
||||
let data = []
|
||||
@@ -87,17 +89,18 @@ const fetchTags = async (kw = '') => {
|
||||
toast.error('获取标签失败')
|
||||
}
|
||||
|
||||
// 3) 合并、去重、可创建
|
||||
let options = [...data, ...localTags.value]
|
||||
|
||||
if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
|
||||
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
|
||||
// 3) 可创建
|
||||
if (
|
||||
props.creatable &&
|
||||
kw &&
|
||||
![...data, ...localTags.value, ...providedTags.value].some(
|
||||
(t) => t.name.toLowerCase() === kw.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
data.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
|
||||
}
|
||||
|
||||
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
|
||||
|
||||
// 4) 最终结果
|
||||
return [defaultOption, ...options]
|
||||
return data
|
||||
}
|
||||
|
||||
const selected = computed({
|
||||
|
||||
Reference in New Issue
Block a user