mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-07-20 03:26:18 +00:00
feat: add pagination for tags
This commit is contained in:
@@ -13,6 +13,7 @@ import com.openisle.service.PostService;
|
|||||||
import com.openisle.service.TagService;
|
import com.openisle.service.TagService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
import io.swagger.v3.oas.annotations.media.Content;
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
@@ -80,7 +81,18 @@ 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 = "limit", required = false) Integer limit,
|
||||||
|
@RequestParam(value = "page", required = false) Integer page,
|
||||||
|
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||||
|
if (page != null && pageSize != null) {
|
||||||
|
Page<Tag> 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<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);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.openisle.model.Tag;
|
|||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -14,6 +15,9 @@ public interface TagRepository extends JpaRepository<Tag, Long> {
|
|||||||
List<Tag> findByApprovedTrue();
|
List<Tag> findByApprovedTrue();
|
||||||
List<Tag> findByNameContainingIgnoreCaseAndApprovedTrue(String keyword);
|
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> findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable);
|
||||||
List<Tag> findByCreator(User creator);
|
List<Tag> findByCreator(User creator);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.springframework.cache.annotation.CacheEvict;
|
|||||||
import org.springframework.cache.annotation.Cacheable;
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -108,6 +109,14 @@ public class TagService {
|
|||||||
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
|
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public 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) {
|
public List<Tag> getRecentTagsByUser(String username, int limit) {
|
||||||
User user = userRepository.findByUsername(username)
|
User user = userRepository.findByUsername(username)
|
||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)"
|
v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)"
|
||||||
:class="['dropdown-menu', menuClass]"
|
:class="['dropdown-menu', menuClass]"
|
||||||
v-click-outside="close"
|
v-click-outside="close"
|
||||||
|
ref="menu"
|
||||||
>
|
>
|
||||||
<div v-if="showSearch" class="dropdown-search">
|
<div v-if="showSearch" class="dropdown-search">
|
||||||
<search-icon class="search-icon" />
|
<search-icon class="search-icon" />
|
||||||
@@ -80,6 +81,13 @@
|
|||||||
<span>{{ o.name }}</span>
|
<span>{{ o.name }}</span>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
|
<InfiniteLoadMore
|
||||||
|
v-if="paginate"
|
||||||
|
:on-load="loadMore"
|
||||||
|
:pause="loading"
|
||||||
|
:root="menu"
|
||||||
|
root-margin="0px"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
@@ -88,7 +96,7 @@
|
|||||||
<next class="back-icon" @click="close" />
|
<next class="back-icon" @click="close" />
|
||||||
<span class="mobile-title">{{ placeholder }}</span>
|
<span class="mobile-title">{{ placeholder }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown-mobile-menu">
|
<div class="dropdown-mobile-menu" ref="mobileMenu">
|
||||||
<div v-if="showSearch" class="dropdown-search">
|
<div v-if="showSearch" class="dropdown-search">
|
||||||
<search-icon class="search-icon" />
|
<search-icon class="search-icon" />
|
||||||
<input type="text" v-model="search" placeholder="搜索" />
|
<input type="text" v-model="search" placeholder="搜索" />
|
||||||
@@ -116,6 +124,13 @@
|
|||||||
<span>{{ o.name }}</span>
|
<span>{{ o.name }}</span>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
|
<InfiniteLoadMore
|
||||||
|
v-if="paginate"
|
||||||
|
:on-load="loadMore"
|
||||||
|
:pause="loading"
|
||||||
|
:root="mobileMenu"
|
||||||
|
root-margin="0px"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,9 +141,11 @@
|
|||||||
<script>
|
<script>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
|
||||||
|
|
||||||
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: '返回' },
|
||||||
@@ -139,6 +156,8 @@ export default {
|
|||||||
optionClass: { type: String, default: '' },
|
optionClass: { type: String, default: '' },
|
||||||
showSearch: { type: Boolean, default: true },
|
showSearch: { type: Boolean, default: true },
|
||||||
initialOptions: { type: Array, default: () => [] },
|
initialOptions: { type: Array, default: () => [] },
|
||||||
|
paginate: { type: Boolean, default: false },
|
||||||
|
pageSize: { type: Number, default: 10 },
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue', 'update:search', 'close'],
|
emits: ['update:modelValue', 'update:search', 'close'],
|
||||||
setup(props, { emit, expose }) {
|
setup(props, { emit, expose }) {
|
||||||
@@ -151,7 +170,11 @@ export default {
|
|||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const wrapper = ref(null)
|
const wrapper = ref(null)
|
||||||
|
const menu = ref(null)
|
||||||
|
const mobileMenu = ref(null)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
const page = ref(0)
|
||||||
|
const done = ref(!props.paginate)
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
open.value = !open.value
|
open.value = !open.value
|
||||||
@@ -186,15 +209,22 @@ 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 (pageNo = 0, kw = '') => {
|
||||||
if (!props.remote && loaded.value) return
|
if (!props.remote && loaded.value && pageNo > 0) return
|
||||||
|
if (done.value) return
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const res = await props.fetchOptions(props.remote ? kw : undefined)
|
const param = props.paginate ? { page: pageNo, keyword: kw } : props.remote ? kw : undefined
|
||||||
options.value = Array.isArray(res) ? res : []
|
const res = await props.fetchOptions(param)
|
||||||
|
const data = Array.isArray(res) ? res : []
|
||||||
|
if (pageNo === 0) options.value = data
|
||||||
|
else options.value.push(...data)
|
||||||
|
if (props.paginate && data.length < props.pageSize) {
|
||||||
|
done.value = true
|
||||||
|
}
|
||||||
if (!props.remote) loaded.value = true
|
if (!props.remote) loaded.value = true
|
||||||
} catch {
|
} catch {
|
||||||
options.value = []
|
if (pageNo === 0) options.value = []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -211,10 +241,12 @@ export default {
|
|||||||
|
|
||||||
watch(open, async (val) => {
|
watch(open, async (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
|
page.value = 0
|
||||||
|
done.value = !props.paginate
|
||||||
if (props.remote) {
|
if (props.remote) {
|
||||||
await loadOptions(search.value)
|
await loadOptions(0, search.value)
|
||||||
} else if (!loaded.value) {
|
} else if (!loaded.value) {
|
||||||
await loadOptions()
|
await loadOptions(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -222,16 +254,26 @@ 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)
|
page.value = 0
|
||||||
|
done.value = !props.paginate
|
||||||
|
await loadOptions(0, val)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!props.remote) {
|
if (!props.remote) {
|
||||||
loadOptions()
|
loadOptions(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (loading.value || done.value) return true
|
||||||
|
const next = page.value + 1
|
||||||
|
await loadOptions(next, search.value)
|
||||||
|
if (!done.value) page.value = next
|
||||||
|
return done.value
|
||||||
|
}
|
||||||
|
|
||||||
const selectedLabels = computed(() => {
|
const selectedLabels = computed(() => {
|
||||||
if (props.multiple) {
|
if (props.multiple) {
|
||||||
return options.value.filter((o) => (props.modelValue || []).includes(o.id))
|
return options.value.filter((o) => (props.modelValue || []).includes(o.id))
|
||||||
@@ -259,12 +301,16 @@ export default {
|
|||||||
search,
|
search,
|
||||||
filteredOptions,
|
filteredOptions,
|
||||||
wrapper,
|
wrapper,
|
||||||
|
menu,
|
||||||
|
mobileMenu,
|
||||||
selectedLabels,
|
selectedLabels,
|
||||||
isSelected,
|
isSelected,
|
||||||
loading,
|
loading,
|
||||||
isImageIcon,
|
isImageIcon,
|
||||||
setSearch,
|
setSearch,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
paginate: props.paginate,
|
||||||
|
loadMore,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const props = defineProps({
|
|||||||
rootMargin: { type: String, default: '200px 0px' },
|
rootMargin: { type: String, default: '200px 0px' },
|
||||||
/** 触发阈值 */
|
/** 触发阈值 */
|
||||||
threshold: { type: Number, default: 0 },
|
threshold: { type: Number, default: 0 },
|
||||||
|
/** 可选:指定滚动容器 */
|
||||||
|
root: { type: [Object, Function], default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
@@ -42,6 +44,13 @@ const stopObserver = () => {
|
|||||||
const startObserver = () => {
|
const startObserver = () => {
|
||||||
if (!import.meta.client || props.pause || done.value) return
|
if (!import.meta.client || props.pause || done.value) return
|
||||||
stopObserver()
|
stopObserver()
|
||||||
|
const getRoot = () => {
|
||||||
|
if (!props.root) return null
|
||||||
|
if (typeof props.root === 'function') return props.root()
|
||||||
|
if (props.root && 'value' in props.root) return props.root.value
|
||||||
|
return props.root
|
||||||
|
}
|
||||||
|
|
||||||
io = new IntersectionObserver(
|
io = new IntersectionObserver(
|
||||||
async (entries) => {
|
async (entries) => {
|
||||||
const e = entries[0]
|
const e = entries[0]
|
||||||
@@ -58,7 +67,7 @@ const startObserver = () => {
|
|||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ root: null, rootMargin: props.rootMargin, threshold: props.threshold },
|
{ root: getRoot(), rootMargin: props.rootMargin, threshold: props.threshold },
|
||||||
)
|
)
|
||||||
if (sentinel.value) io.observe(sentinel.value)
|
if (sentinel.value) io.observe(sentinel.value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<nav v-if="visible" class="menu">
|
<nav v-if="visible" class="menu">
|
||||||
<div class="menu-content">
|
<div class="menu-content" ref="menuContent">
|
||||||
<div class="menu-item-container">
|
<div class="menu-item-container">
|
||||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||||
<hashtag-key class="menu-item-icon" />
|
<hashtag-key class="menu-item-icon" />
|
||||||
@@ -115,23 +115,32 @@
|
|||||||
<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)">
|
<template 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="tagData.length > 0"
|
||||||
|
:on-load="loadMoreTags"
|
||||||
|
:pause="isLoadingTag"
|
||||||
|
:root="menuContent"
|
||||||
|
root-margin="0px"
|
||||||
/>
|
/>
|
||||||
<component
|
</template>
|
||||||
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>
|
</div>
|
||||||
@@ -150,6 +159,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
|
||||||
import { authState, fetchCurrentUser } from '~/utils/auth'
|
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'
|
||||||
@@ -168,6 +178,8 @@ const emit = defineEmits(['item-click'])
|
|||||||
const categoryOpen = ref(true)
|
const categoryOpen = ref(true)
|
||||||
const tagOpen = ref(true)
|
const tagOpen = ref(true)
|
||||||
const myPoint = ref(null)
|
const myPoint = ref(null)
|
||||||
|
const menuContent = ref(null)
|
||||||
|
const tagPage = ref(0)
|
||||||
|
|
||||||
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
||||||
const {
|
const {
|
||||||
@@ -190,12 +202,22 @@ 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('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?page=0&pageSize=20`), {
|
||||||
server: true,
|
server: true,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
staleTime: 5 * 60 * 1000,
|
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`)
|
||||||
|
const data = Array.isArray(res) ? res : []
|
||||||
|
tagData.value.push(...data)
|
||||||
|
if (data.length < 20) return true
|
||||||
|
tagPage.value = next
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/** 其余逻辑保持不变 */
|
/** 其余逻辑保持不变 */
|
||||||
const iconClass = computed(() => {
|
const iconClass = computed(() => {
|
||||||
switch (themeState.mode) {
|
switch (themeState.mode) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
placeholder="选择标签"
|
placeholder="选择标签"
|
||||||
remote
|
remote
|
||||||
:initial-options="mergedOptions"
|
:initial-options="mergedOptions"
|
||||||
|
paginate
|
||||||
|
:page-size="10"
|
||||||
>
|
>
|
||||||
<template #option="{ option }">
|
<template #option="{ option }">
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
@@ -62,21 +64,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 ({ keyword = '', 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(keyword, page)
|
||||||
|
|
||||||
// 2) 拉数据
|
// 2) 拉数据
|
||||||
let data = []
|
let data = []
|
||||||
@@ -90,14 +93,18 @@ const fetchTags = async (kw = '') => {
|
|||||||
// 3) 合并、去重、可创建
|
// 3) 合并、去重、可创建
|
||||||
let options = [...data, ...localTags.value]
|
let options = [...data, ...localTags.value]
|
||||||
|
|
||||||
if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
|
if (
|
||||||
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
|
props.creatable &&
|
||||||
|
keyword &&
|
||||||
|
!options.some((t) => t.name.toLowerCase() === keyword.toLowerCase())
|
||||||
|
) {
|
||||||
|
options.push({ id: `__create__:${keyword}`, name: `创建"${keyword}"` })
|
||||||
}
|
}
|
||||||
|
|
||||||
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) 最终结果
|
// 4) 最终结果
|
||||||
return [defaultOption, ...options]
|
return page === 0 ? [defaultOption, ...options] : options
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = computed({
|
const selected = computed({
|
||||||
|
|||||||
Reference in New Issue
Block a user