mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-20 05:50:53 +08:00
Add paginated tag fetching for backend and UI
This commit is contained in:
@@ -2,6 +2,7 @@ package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.PostSummaryDto;
|
||||
import com.openisle.dto.TagDto;
|
||||
import com.openisle.dto.TagPageResponse;
|
||||
import com.openisle.dto.TagRequest;
|
||||
import com.openisle.mapper.PostMapper;
|
||||
import com.openisle.mapper.TagMapper;
|
||||
@@ -19,8 +20,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@@ -96,24 +102,45 @@ public class TagController {
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "List of tags",
|
||||
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
|
||||
content = @Content(schema = @Schema(implementation = TagPageResponse.class))
|
||||
)
|
||||
public List<TagDto> list(
|
||||
public TagPageResponse 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
|
||||
) {
|
||||
List<Tag> tags = tagService.searchTags(keyword);
|
||||
int resolvedPageSize = Optional.ofNullable(pageSize)
|
||||
.filter(s -> s > 0)
|
||||
.or(() -> Optional.ofNullable(limit).filter(l -> l > 0))
|
||||
.orElse(20);
|
||||
int resolvedPage = Optional.ofNullable(page)
|
||||
.filter(p -> p >= 0)
|
||||
.orElse(0);
|
||||
|
||||
Pageable pageable = PageRequest.of(
|
||||
resolvedPage,
|
||||
resolvedPageSize,
|
||||
Sort.by(Sort.Direction.DESC, "createdAt")
|
||||
);
|
||||
Page<Tag> tagPage = tagService.searchTags(keyword, pageable);
|
||||
List<Tag> tags = tagPage.getContent();
|
||||
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
||||
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
|
||||
Map<Long, Long> postCntByTagIds = Optional.ofNullable(
|
||||
postService.countPostsByTagIds(tagIds)
|
||||
).orElseGet(Map::of);
|
||||
List<TagDto> 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 (limit != null && limit > 0 && dtos.size() > limit) {
|
||||
return dtos.subList(0, limit);
|
||||
}
|
||||
return dtos;
|
||||
|
||||
return new TagPageResponse(
|
||||
dtos,
|
||||
tagPage.getNumber(),
|
||||
tagPage.getSize(),
|
||||
tagPage.getTotalElements(),
|
||||
tagPage.hasNext()
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
|
||||
18
backend/src/main/java/com/openisle/dto/TagPageResponse.java
Normal file
18
backend/src/main/java/com/openisle/dto/TagPageResponse.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TagPageResponse {
|
||||
|
||||
private List<TagDto> items;
|
||||
private int page;
|
||||
private int pageSize;
|
||||
private long total;
|
||||
private boolean hasNext;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.openisle.model.Tag;
|
||||
import com.openisle.model.User;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
@@ -12,6 +13,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);
|
||||
|
||||
@@ -9,8 +9,11 @@ import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@@ -113,16 +116,37 @@ public class TagService {
|
||||
* @param keyword
|
||||
* @return
|
||||
*/
|
||||
public List<Tag> searchTags(String keyword) {
|
||||
return searchTags(keyword, Pageable.unpaged()).getContent();
|
||||
}
|
||||
|
||||
@Cacheable(
|
||||
value = CachingConfig.TAG_CACHE_NAME,
|
||||
key = "'searchTags:' + (#keyword ?: '')" //keyword为null的场合返回空
|
||||
key = "'searchTags:' + (#keyword ?: '') + ':' + (#pageable != null && #pageable.isPaged() ? #pageable.pageNumber : 0) + ':' + (#pageable != null && #pageable.isPaged() ? #pageable.pageSize : 0)"
|
||||
)
|
||||
public List<Tag> searchTags(String keyword) {
|
||||
if (keyword == null || keyword.isBlank()) {
|
||||
return tagRepository.findByApprovedTrue();
|
||||
public Page<Tag> searchTags(String keyword, Pageable pageable) {
|
||||
Pageable effectivePageable = pageable != null ? pageable : Pageable.unpaged();
|
||||
boolean hasKeyword = keyword != null && !keyword.isBlank();
|
||||
|
||||
if (!effectivePageable.isPaged()) {
|
||||
List<Tag> tags = hasKeyword
|
||||
? tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword)
|
||||
: tagRepository.findByApprovedTrue();
|
||||
return new PageImpl<>(tags, Pageable.unpaged(), tags.size());
|
||||
}
|
||||
|
||||
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
|
||||
Pageable sortedPageable = effectivePageable;
|
||||
if (effectivePageable.getSort().isUnsorted()) {
|
||||
sortedPageable = PageRequest.of(
|
||||
effectivePageable.getPageNumber(),
|
||||
effectivePageable.getPageSize(),
|
||||
Sort.by(Sort.Direction.DESC, "createdAt")
|
||||
);
|
||||
}
|
||||
|
||||
return hasKeyword
|
||||
? tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword, sortedPageable)
|
||||
: tagRepository.findByApprovedTrue(sortedPageable);
|
||||
}
|
||||
|
||||
public List<Tag> getRecentTagsByUser(String username, int limit) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.PostService;
|
||||
import com.openisle.service.TagService;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -19,6 +20,8 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@@ -81,16 +84,26 @@ class TagControllerTest {
|
||||
t.setDescription("d2");
|
||||
t.setIcon("i2");
|
||||
t.setSmallIcon("s2");
|
||||
Mockito.when(tagService.searchTags(null)).thenReturn(List.of(t));
|
||||
Mockito.when(tagService.searchTags(Mockito.isNull(), Mockito.any(Pageable.class))).thenAnswer(
|
||||
invocation -> {
|
||||
Pageable pageable = invocation.getArgument(1, Pageable.class);
|
||||
return new PageImpl<>(List.of(t), pageable, 1);
|
||||
}
|
||||
);
|
||||
Mockito.when(postService.countPostsByTagIds(List.of(2L))).thenReturn(Map.of(2L, 3L));
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/tags"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].name").value("spring"))
|
||||
.andExpect(jsonPath("$[0].description").value("d2"))
|
||||
.andExpect(jsonPath("$[0].icon").value("i2"))
|
||||
.andExpect(jsonPath("$[0].smallIcon").value("s2"));
|
||||
.andExpect(jsonPath("$.items[0].name").value("spring"))
|
||||
.andExpect(jsonPath("$.items[0].description").value("d2"))
|
||||
.andExpect(jsonPath("$.items[0].icon").value("i2"))
|
||||
.andExpect(jsonPath("$.items[0].smallIcon").value("s2"))
|
||||
.andExpect(jsonPath("$.items[0].count").value(3))
|
||||
.andExpect(jsonPath("$.page").value(0))
|
||||
.andExpect(jsonPath("$.pageSize").value(20))
|
||||
.andExpect(jsonPath("$.hasNext").value(false))
|
||||
.andExpect(jsonPath("$.total").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="footer" :close="close"></slot>
|
||||
</template>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
@@ -116,6 +117,7 @@
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="footer" :close="close"></slot>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,30 +116,41 @@
|
||||
<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"
|
||||
:class="{ selected: isTagSelected(t.id) }"
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="t in tagItems"
|
||||
:key="t.id"
|
||||
class="section-item"
|
||||
:class="{ selected: isTagSelected(t.id) }"
|
||||
@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
|
||||
<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>
|
||||
<button
|
||||
v-if="tagPagination.hasNext"
|
||||
type="button"
|
||||
class="menu-more"
|
||||
:disabled="isLoadingMoreTags"
|
||||
@click.stop="loadMoreMenuTags"
|
||||
>
|
||||
</div>
|
||||
<span v-if="!isLoadingMoreTags">查看更多</span>
|
||||
<span v-else>加载中...</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +168,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
@@ -180,6 +191,7 @@ const isTagSelected = (id) => {
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const TAG_PAGE_SIZE = 10
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: true },
|
||||
@@ -207,15 +219,51 @@ const {
|
||||
},
|
||||
)
|
||||
|
||||
const tagItems = ref([])
|
||||
const tagPagination = reactive({
|
||||
page: 0,
|
||||
pageSize: TAG_PAGE_SIZE,
|
||||
hasNext: false,
|
||||
total: 0,
|
||||
})
|
||||
const isLoadingMoreTags = ref(false)
|
||||
|
||||
const {
|
||||
data: tagData,
|
||||
data: tagPage,
|
||||
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',
|
||||
() =>
|
||||
$fetch(`${API_BASE_URL}/api/tags`, {
|
||||
params: { page: 0, pageSize: TAG_PAGE_SIZE },
|
||||
}),
|
||||
{
|
||||
server: true,
|
||||
default: () => ({
|
||||
items: [],
|
||||
page: 0,
|
||||
pageSize: TAG_PAGE_SIZE,
|
||||
hasNext: false,
|
||||
total: 0,
|
||||
}),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => tagPage.value,
|
||||
(val) => {
|
||||
if (!val) return
|
||||
const items = Array.isArray(val.items) ? val.items : []
|
||||
tagItems.value = items
|
||||
tagPagination.page = val.page ?? 0
|
||||
tagPagination.pageSize = val.pageSize ?? TAG_PAGE_SIZE
|
||||
tagPagination.hasNext = Boolean(val.hasNext)
|
||||
tagPagination.total = val.total ?? items.length
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 其余逻辑保持不变 */
|
||||
const iconClass = computed(() => {
|
||||
@@ -383,6 +431,23 @@ const gotoTag = (t) => {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.menu-more {
|
||||
margin: 10px auto;
|
||||
width: calc(100% - 20px);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 6px 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.menu-more[disabled] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
position: relation;
|
||||
height: 30px;
|
||||
@@ -441,7 +506,6 @@ const gotoTag = (t) => {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
|
||||
.section-item-text-count {
|
||||
font-size: 12px;
|
||||
color: var(--menu-text-color);
|
||||
@@ -508,3 +572,11 @@ const gotoTag = (t) => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
const loadMoreMenuTags = async () => { if (!tagPagination.hasNext || isLoadingMoreTags.value) return
|
||||
isLoadingMoreTags.value = true try { const res = await $fetch(`${API_BASE_URL}/api/tags`, { params:
|
||||
{ page: tagPagination.page + 1, pageSize: tagPagination.pageSize }, }) const items =
|
||||
Array.isArray(res?.items) ? res.items : [] tagItems.value = [...tagItems.value, ...items]
|
||||
tagPagination.page = res?.page ?? tagPagination.page + 1 tagPagination.pageSize = res?.pageSize ??
|
||||
tagPagination.pageSize tagPagination.hasNext = Boolean(res?.hasNext) tagPagination.total =
|
||||
res?.total ?? tagPagination.total } catch (error) { console.error(error) } finally {
|
||||
isLoadingMoreTags.value = false } }
|
||||
|
||||
@@ -25,16 +25,32 @@
|
||||
<div v-if="option.description" class="option-desc">{{ option.description }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div v-if="tagPagination.hasNext" class="dropdown-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-more"
|
||||
:disabled="isLoadingMore"
|
||||
@click.stop.prevent="loadMoreTags"
|
||||
>
|
||||
<span v-if="!isLoadingMore">查看更多</span>
|
||||
<span v-else>加载中...</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { toast } from '~/main'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const TAG_PAGE_SIZE = 10
|
||||
const defaultOption = { id: 0, name: '无标签' }
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
@@ -44,6 +60,15 @@ const props = defineProps({
|
||||
|
||||
const localTags = ref([])
|
||||
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||
const remoteTags = ref([])
|
||||
const isLoadingMore = ref(false)
|
||||
const tagPagination = reactive({
|
||||
keyword: '',
|
||||
page: 0,
|
||||
pageSize: TAG_PAGE_SIZE,
|
||||
hasNext: false,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
@@ -52,54 +77,93 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const mergedOptions = computed(() => {
|
||||
const arr = [...providedTags.value, ...localTags.value]
|
||||
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
|
||||
})
|
||||
|
||||
const isImageIcon = (icon) => {
|
||||
if (!icon) return false
|
||||
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', String(tagPagination.pageSize))
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const fetchTags = async (kw = '') => {
|
||||
const defaultOption = { id: 0, name: '无标签' }
|
||||
if (tagPagination.keyword !== kw) {
|
||||
tagPagination.keyword = kw
|
||||
tagPagination.page = 0
|
||||
}
|
||||
|
||||
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
||||
const url = buildTagsUrl(kw)
|
||||
const url = buildTagsUrl(kw, 0)
|
||||
|
||||
// 2) 拉数据
|
||||
let data = []
|
||||
let pageData
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.ok) data = await res.json()
|
||||
if (!res.ok) throw new Error('failed to fetch tags')
|
||||
pageData = await res.json()
|
||||
} catch {
|
||||
toast.error('获取标签失败')
|
||||
remoteTags.value = []
|
||||
tagPagination.hasNext = false
|
||||
tagPagination.total = 0
|
||||
return buildOptions([])
|
||||
}
|
||||
|
||||
// 3) 合并、去重、可创建
|
||||
let options = [...data, ...localTags.value]
|
||||
const items = Array.isArray(pageData?.items) ? pageData.items : []
|
||||
remoteTags.value = items
|
||||
tagPagination.page = pageData?.page ?? 0
|
||||
tagPagination.pageSize = pageData?.pageSize ?? TAG_PAGE_SIZE
|
||||
tagPagination.hasNext = Boolean(pageData?.hasNext)
|
||||
tagPagination.total = pageData?.total ?? items.length
|
||||
|
||||
if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
|
||||
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
|
||||
return buildOptions(items)
|
||||
}
|
||||
|
||||
const buildOptions = (remote = remoteTags.value) => {
|
||||
let options = [...remote, ...localTags.value]
|
||||
if (props.creatable && tagPagination.keyword) {
|
||||
const lowerKw = tagPagination.keyword.toLowerCase()
|
||||
if (!options.some((t) => typeof t.name === 'string' && t.name.toLowerCase() === lowerKw)) {
|
||||
options.push({
|
||||
id: `__create__:${tagPagination.keyword}`,
|
||||
name: `创建"${tagPagination.keyword}"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
options = [...providedTags.value, ...options]
|
||||
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
|
||||
|
||||
// 4) 最终结果
|
||||
return [defaultOption, ...options]
|
||||
}
|
||||
|
||||
const mergedOptions = computed(() => buildOptions(remoteTags.value))
|
||||
|
||||
const loadMoreTags = async () => {
|
||||
if (!tagPagination.hasNext || isLoadingMore.value) return
|
||||
isLoadingMore.value = true
|
||||
try {
|
||||
const nextPage = tagPagination.page + 1
|
||||
const url = buildTagsUrl(tagPagination.keyword, nextPage)
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error('failed to fetch tags')
|
||||
const pageData = await res.json()
|
||||
const items = Array.isArray(pageData?.items) ? pageData.items : []
|
||||
remoteTags.value = [...remoteTags.value, ...items]
|
||||
tagPagination.page = pageData?.page ?? nextPage
|
||||
tagPagination.pageSize = pageData?.pageSize ?? tagPagination.pageSize
|
||||
tagPagination.hasNext = Boolean(pageData?.hasNext)
|
||||
tagPagination.total = pageData?.total ?? tagPagination.total
|
||||
} catch {
|
||||
toast.error('获取标签失败')
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => {
|
||||
@@ -151,4 +215,24 @@ const selected = computed({
|
||||
font-weight: bold;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.dropdown-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
|
||||
.dropdown-more {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.dropdown-more[disabled] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user