mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-11 13:17:29 +08:00
feat: add tag pagination
This commit is contained in:
@@ -80,7 +80,8 @@ public class TagController {
|
|||||||
@ApiResponse(responseCode = "200", description = "List of tags",
|
@ApiResponse(responseCode = "200", description = "List of tags",
|
||||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))))
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))))
|
||||||
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@RequestParam(value = "page", required = false) Integer page,
|
||||||
|
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||||
List<Tag> tags = tagService.searchTags(keyword);
|
List<Tag> tags = tagService.searchTags(keyword);
|
||||||
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
||||||
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
|
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
|
||||||
@@ -88,10 +89,11 @@ public class TagController {
|
|||||||
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
|
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
|
||||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
if (limit != null && limit > 0 && dtos.size() > limit) {
|
int p = page != null && page >= 0 ? page : 0;
|
||||||
return dtos.subList(0, limit);
|
int ps = pageSize != null && pageSize > 0 ? pageSize : dtos.size();
|
||||||
}
|
int from = Math.min(p * ps, dtos.size());
|
||||||
return dtos;
|
int to = Math.min(from + ps, dtos.size());
|
||||||
|
return dtos.subList(from, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
|
|||||||
@@ -80,6 +80,12 @@
|
|||||||
<span>{{ o.name }}</span>
|
<span>{{ o.name }}</span>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
|
<InfiniteLoadMore
|
||||||
|
v-if="hasMore"
|
||||||
|
:on-load="loadMore"
|
||||||
|
:pause="loading"
|
||||||
|
root-margin="100px 0px"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
@@ -116,6 +122,12 @@
|
|||||||
<span>{{ o.name }}</span>
|
<span>{{ o.name }}</span>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
|
<InfiniteLoadMore
|
||||||
|
v-if="hasMore"
|
||||||
|
:on-load="loadMore"
|
||||||
|
:pause="loading"
|
||||||
|
root-margin="100px 0px"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,10 +137,12 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'BaseDropdown',
|
name: 'BaseDropdown',
|
||||||
|
components: { InfiniteLoadMore },
|
||||||
props: {
|
props: {
|
||||||
modelValue: { type: [Array, String, Number], default: () => [] },
|
modelValue: { type: [Array, String, Number], default: () => [] },
|
||||||
placeholder: { type: String, default: '返回' },
|
placeholder: { type: String, default: '返回' },
|
||||||
@@ -150,6 +164,8 @@ export default {
|
|||||||
const options = ref(Array.isArray(props.initialOptions) ? [...props.initialOptions] : [])
|
const options = ref(Array.isArray(props.initialOptions) ? [...props.initialOptions] : [])
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
const page = ref(0)
|
||||||
const wrapper = ref(null)
|
const wrapper = ref(null)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
@@ -186,20 +202,43 @@ export default {
|
|||||||
return options.value.filter((o) => o.name.toLowerCase().includes(search.value.toLowerCase()))
|
return options.value.filter((o) => o.name.toLowerCase().includes(search.value.toLowerCase()))
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadOptions = async (kw = '') => {
|
const loadOptions = async (kw = '', reset = false) => {
|
||||||
if (!props.remote && loaded.value) return
|
if (loading.value || (!hasMore.value && !reset)) return
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const res = await props.fetchOptions(props.remote ? kw : undefined)
|
if (reset) {
|
||||||
options.value = Array.isArray(res) ? res : []
|
page.value = 0
|
||||||
|
hasMore.value = true
|
||||||
|
options.value = []
|
||||||
|
}
|
||||||
|
const res = await props.fetchOptions(props.remote ? kw : undefined, page.value)
|
||||||
|
let data = []
|
||||||
|
let done = false
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
data = res
|
||||||
|
done = true
|
||||||
|
} else if (res) {
|
||||||
|
data = Array.isArray(res.data) ? res.data : []
|
||||||
|
done = !!res.done
|
||||||
|
}
|
||||||
|
options.value = options.value.concat(data)
|
||||||
|
if (done || data.length === 0) {
|
||||||
|
hasMore.value = false
|
||||||
|
} else {
|
||||||
|
page.value += 1
|
||||||
|
}
|
||||||
if (!props.remote) loaded.value = true
|
if (!props.remote) loaded.value = true
|
||||||
} catch {
|
} catch {
|
||||||
options.value = []
|
if (reset) options.value = []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
await loadOptions(search.value)
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.initialOptions,
|
() => props.initialOptions,
|
||||||
(val) => {
|
(val) => {
|
||||||
@@ -212,9 +251,9 @@ export default {
|
|||||||
watch(open, async (val) => {
|
watch(open, async (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
if (props.remote) {
|
if (props.remote) {
|
||||||
await loadOptions(search.value)
|
await loadOptions(search.value, true)
|
||||||
} else if (!loaded.value) {
|
} else if (!loaded.value) {
|
||||||
await loadOptions()
|
await loadOptions('', true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -222,13 +261,13 @@ export default {
|
|||||||
watch(search, async (val) => {
|
watch(search, async (val) => {
|
||||||
emit('update:search', val)
|
emit('update:search', val)
|
||||||
if (props.remote && open.value) {
|
if (props.remote && open.value) {
|
||||||
await loadOptions(val)
|
await loadOptions(val, true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!props.remote) {
|
if (!props.remote) {
|
||||||
loadOptions()
|
loadOptions('', true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -265,6 +304,8 @@ export default {
|
|||||||
isImageIcon,
|
isImageIcon,
|
||||||
setSearch,
|
setSearch,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
loadMore,
|
||||||
|
hasMore,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,22 +115,30 @@
|
|||||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
<div v-else>
|
||||||
<BaseImage
|
<div v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
||||||
v-if="isImageIcon(t.smallIcon || t.icon)"
|
<BaseImage
|
||||||
:src="t.smallIcon || t.icon"
|
v-if="isImageIcon(t.smallIcon || t.icon)"
|
||||||
class="section-item-icon"
|
:src="t.smallIcon || t.icon"
|
||||||
:alt="t.name"
|
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="hasMoreTags"
|
||||||
|
:on-load="fetchMoreTags"
|
||||||
|
:pause="loadingMoreTags"
|
||||||
|
root-margin="200px 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,6 +162,7 @@ import { authState, fetchCurrentUser } from '~/utils/auth'
|
|||||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
|
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
|
||||||
|
import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
|
||||||
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
@@ -186,15 +195,47 @@ const {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tagPageSize = 10
|
||||||
const {
|
const {
|
||||||
data: tagData,
|
data: tagData,
|
||||||
pending: isLoadingTag,
|
pending: isLoadingTag,
|
||||||
error: tagError,
|
error: tagError,
|
||||||
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
|
} = await useAsyncData(
|
||||||
server: true,
|
'menu:tags',
|
||||||
default: () => [],
|
() => $fetch(`${API_BASE_URL}/api/tags?page=0&pageSize=${tagPageSize}`),
|
||||||
staleTime: 5 * 60 * 1000,
|
{
|
||||||
})
|
server: true,
|
||||||
|
default: () => [],
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const tagPage = ref(1)
|
||||||
|
const hasMoreTags = ref(tagData.value.length === tagPageSize)
|
||||||
|
const loadingMoreTags = ref(false)
|
||||||
|
|
||||||
|
const fetchMoreTags = async () => {
|
||||||
|
if (loadingMoreTags.value || !hasMoreTags.value) return true
|
||||||
|
loadingMoreTags.value = true
|
||||||
|
try {
|
||||||
|
const data = await $fetch(
|
||||||
|
`${API_BASE_URL}/api/tags?page=${tagPage.value}&pageSize=${tagPageSize}`,
|
||||||
|
)
|
||||||
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
|
tagData.value.push(...data)
|
||||||
|
tagPage.value++
|
||||||
|
if (data.length < tagPageSize) {
|
||||||
|
hasMoreTags.value = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
hasMoreTags.value = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingMoreTags.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 其余逻辑保持不变 */
|
/** 其余逻辑保持不变 */
|
||||||
const iconClass = computed(() => {
|
const iconClass = computed(() => {
|
||||||
|
|||||||
@@ -62,21 +62,22 @@ const isImageIcon = (icon) => {
|
|||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
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 base = API_BASE_URL || (import.meta.client ? window.location.origin : '')
|
||||||
const url = new URL('/api/tags', base)
|
const url = new URL('/api/tags', base)
|
||||||
|
|
||||||
if (kw) url.searchParams.set('keyword', kw)
|
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()
|
return url.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchTags = async (kw = '') => {
|
const fetchTags = async (kw = '', page = 0) => {
|
||||||
const defaultOption = { id: 0, name: '无标签' }
|
const defaultOption = { id: 0, name: '无标签' }
|
||||||
|
|
||||||
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
||||||
const url = buildTagsUrl(kw)
|
const url = buildTagsUrl(kw, page)
|
||||||
|
|
||||||
// 2) 拉数据
|
// 2) 拉数据
|
||||||
let data = []
|
let data = []
|
||||||
@@ -96,8 +97,12 @@ const fetchTags = async (kw = '') => {
|
|||||||
|
|
||||||
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
|
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
|
||||||
|
|
||||||
// 4) 最终结果
|
const done = data.length < 10
|
||||||
return [defaultOption, ...options]
|
if (page === 0) {
|
||||||
|
options = [defaultOption, ...options]
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: options, done }
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = computed({
|
const selected = computed({
|
||||||
|
|||||||
Reference in New Issue
Block a user