Add paginated tag fetching for backend and UI

This commit is contained in:
Tim
2025-09-24 15:37:26 +08:00
parent 2b5f6f2208
commit 6cd3cf47ef
8 changed files with 314 additions and 71 deletions

View File

@@ -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>

View File

@@ -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 } }

View File

@@ -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>