refactor: implement buffered streaming updates and disable keep-alive for resource

This commit is contained in:
jxxghp
2026-05-09 13:22:40 +08:00
parent 58fdb77b37
commit a475085d7b
2 changed files with 133 additions and 21 deletions
+133 -20
View File
@@ -140,13 +140,23 @@ const errorDescription = ref(t('resource.noResourceFound'))
let searchEventSource: EventSource | null = null let searchEventSource: EventSource | null = null
const streamPreviewLimit = 24 const streamPreviewLimit = 24
const streamUiFlushDelay = 1000
const streamPreviewBufferLimit = streamPreviewLimit * 4
const streamTotalCount = ref(0) const streamTotalCount = ref(0)
const streamPreviewDataList = ref<Array<Context>>([])
const displayResourceCount = computed(() => const displayResourceCount = computed(() =>
progressActive.value ? streamTotalCount.value : torrentFilter.totalFilteredCount.value, progressActive.value ? streamTotalCount.value : torrentFilter.totalFilteredCount.value,
) )
let pendingStreamItems: Array<Context> = []
let streamFlushTimer: ReturnType<typeof setTimeout> | null = null
let streamFinalResultApplied = false
let pendingProgressText: string | null = null
let pendingProgressValue: number | null = null
let pendingStreamTotalCount: number | null = null
// 监听筛选条件变化,重新筛选数据 // 监听筛选条件变化,重新筛选数据
watch( watch(
[() => torrentFilter.filterForm, () => torrentFilter.sortField.value, () => torrentFilter.sortType.value], [() => torrentFilter.filterForm, () => torrentFilter.sortField.value, () => torrentFilter.sortType.value],
@@ -231,6 +241,58 @@ function closeSearchEventSource() {
} }
} }
// 渐进式搜索期间只保留有限预览数据,避免每个批次都触发完整筛选和分组计算。
function clearStreamFlushTimer() {
if (streamFlushTimer) {
clearTimeout(streamFlushTimer)
streamFlushTimer = null
}
}
function clearStreamPreviewState(resetFinalState: boolean = false) {
clearStreamFlushTimer()
pendingStreamItems = []
pendingProgressText = null
pendingProgressValue = null
pendingStreamTotalCount = null
streamPreviewDataList.value = []
if (resetFinalState) {
streamFinalResultApplied = false
}
}
// 将进度和预览列表放到同一个节奏刷新,避免 SSE 到来时多处 UI 各自抖动。
function flushBufferedStreamState() {
clearStreamFlushTimer()
if (pendingProgressText !== null) {
progressText.value = pendingProgressText
}
if (pendingProgressValue !== null) {
progressValue.value = pendingProgressValue
}
if (pendingStreamTotalCount !== null) {
streamTotalCount.value = pendingStreamTotalCount
}
pendingProgressText = null
pendingProgressValue = null
pendingStreamTotalCount = null
if (!pendingStreamItems.length) return
streamPreviewDataList.value = [...pendingStreamItems, ...streamPreviewDataList.value].slice(0, streamPreviewLimit)
pendingStreamItems = []
isRefreshed.value = true
}
function scheduleStreamFlush() {
if (streamFlushTimer) return
streamFlushTimer = setTimeout(() => {
flushBufferedStreamState()
}, streamUiFlushDelay)
}
// 获取API URL // 获取API URL
function getApiUrl(path: string) { function getApiUrl(path: string) {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
@@ -270,6 +332,7 @@ function buildSearchStreamUrl() {
// 重置搜索结果 // 重置搜索结果
function resetSearchResults() { function resetSearchResults() {
clearStreamPreviewState(true)
rawDataList.value = [] rawDataList.value = []
originalDataList.value = [] originalDataList.value = []
streamTotalCount.value = 0 streamTotalCount.value = 0
@@ -283,21 +346,28 @@ function resetSearchResults() {
} }
// 更新搜索进度 // 更新搜索进度
function updateSearchProgress(eventData: { [key: string]: any }) { function updateSearchProgress(eventData: { [key: string]: any }, flushNow: boolean = false) {
if (eventData.text) { if (eventData.text) {
progressText.value = eventData.text pendingProgressText = eventData.text
} }
if (typeof eventData.value === 'number') { if (typeof eventData.value === 'number') {
progressValue.value = eventData.value pendingProgressValue = eventData.value
} }
if (typeof eventData.total_items === 'number') { if (typeof eventData.total_items === 'number') {
streamTotalCount.value = eventData.total_items pendingStreamTotalCount = eventData.total_items
} }
progressEnabled.value = true progressEnabled.value = true
if (flushNow) {
flushBufferedStreamState()
} else {
scheduleStreamFlush()
}
} }
// 设置流式搜索结果 // 设置流式搜索结果
function setStreamResults(items: Context[]) { function setStreamResults(items: Context[]) {
clearStreamPreviewState()
rawDataList.value = items rawDataList.value = items
originalDataList.value = items originalDataList.value = items
if (!progressActive.value) { if (!progressActive.value) {
@@ -307,12 +377,21 @@ function setStreamResults(items: Context[]) {
applyFilter() applyFilter()
} }
// 追加流式搜索结果 // 追加流式搜索预览结果
function appendStreamResults(items: Context[]) { function appendStreamResults(items: Context[]) {
if (!items.length) return if (!items.length) return
const nextItems = [...items, ...rawDataList.value] pendingStreamItems.unshift(...items)
setStreamResults(progressActive.value ? nextItems.slice(0, streamPreviewLimit) : nextItems) if (pendingStreamItems.length > streamPreviewBufferLimit) {
pendingStreamItems = pendingStreamItems.slice(0, streamPreviewBufferLimit)
}
scheduleStreamFlush()
}
function applyFinalStreamResults(items: Context[]) {
streamFinalResultApplied = true
flushBufferedStreamState()
setStreamResults(items)
} }
// 获取磁力链接的key // 获取磁力链接的key
@@ -327,18 +406,24 @@ function getTorrentItemKey(item: Context, index: number) {
// 处理搜索流消息 // 处理搜索流消息
function handleSearchStreamMessage(eventData: { [key: string]: any }) { function handleSearchStreamMessage(eventData: { [key: string]: any }) {
updateSearchProgress(eventData)
if (eventData.type === 'error') { if (eventData.type === 'error') {
updateSearchProgress(eventData, true)
errorDescription.value = eventData.message || t('resource.noResourceFound') errorDescription.value = eventData.message || t('resource.noResourceFound')
return return
} }
const items = Array.isArray(eventData.items) ? (eventData.items as Context[]) : [] const items = Array.isArray(eventData.items) ? (eventData.items as Context[]) : []
if (eventData.type === 'append') { if (eventData.type === 'append') {
updateSearchProgress(eventData)
appendStreamResults(items) appendStreamResults(items)
} else if (eventData.type === 'replace' || eventData.type === 'done') { } else if (eventData.type === 'replace') {
setStreamResults(items) updateSearchProgress(eventData, true)
applyFinalStreamResults(items)
} else if (eventData.type === 'done' && items.length > 0 && !streamFinalResultApplied) {
updateSearchProgress(eventData, true)
applyFinalStreamResults(items)
} else {
updateSearchProgress(eventData)
} }
} }
@@ -439,8 +524,7 @@ async function fetchData() {
if (!keyword) { if (!keyword) {
// 查询上次搜索结果 // 查询上次搜索结果
const results = await api.get('search/last') const results = await api.get('search/last')
rawDataList.value = (results as unknown as Context[]) || [] setStreamResults((results as unknown as Context[]) || [])
originalDataList.value = (results as unknown as Context[]) || []
} else { } else {
resetSearchResults() resetSearchResults()
startLoadingProgress() startLoadingProgress()
@@ -454,8 +538,6 @@ async function fetchData() {
// 从浏览器历史中删除当前搜索 // 从浏览器历史中删除当前搜索
window.history.replaceState(null, '', window.location.pathname) window.history.replaceState(null, '', window.location.pathname)
} }
// 应用筛选
applyFilter()
// 标记已刷新 // 标记已刷新
isRefreshed.value = true isRefreshed.value = true
} catch (error) { } catch (error) {
@@ -731,6 +813,10 @@ async function checkAiRecommendStatus() {
// 计算当前显示的数据是否有数据 // 计算当前显示的数据是否有数据
const hasData = computed(() => { const hasData = computed(() => {
if (progressActive.value) {
return streamPreviewDataList.value.length > 0 || rawDataList.value.length > 0
}
if (viewType.value === 'row') { if (viewType.value === 'row') {
return filteredRowDataList.value.length > 0 || rawDataList.value.length > 0 return filteredRowDataList.value.length > 0 || rawDataList.value.length > 0
} else { } else {
@@ -762,6 +848,7 @@ onUnmounted(() => {
closeSearchEventSource() closeSearchEventSource()
stopLoadingProgress() stopLoadingProgress()
stopAiRecommendPolling() stopAiRecommendPolling()
clearStreamPreviewState()
}) })
</script> </script>
@@ -769,7 +856,11 @@ onUnmounted(() => {
<div> <div>
<!-- 搜索加载状态 --> <!-- 搜索加载状态 -->
<VFadeTransition> <VFadeTransition>
<div v-if="isSearchProgressVisible" class="search-loading-state mb-3" :class="{ 'is-empty-loading': isSearchLoading }"> <div
v-if="isSearchProgressVisible"
class="search-loading-state mb-3"
:class="{ 'is-empty-loading': isSearchLoading }"
>
<VCard elevation="0" class="search-progress-card"> <VCard elevation="0" class="search-progress-card">
<div class="progress-header"> <div class="progress-header">
<div class="progress-icon-wrap"> <div class="progress-icon-wrap">
@@ -955,8 +1046,20 @@ onUnmounted(() => {
<VFadeTransition mode="out-in"> <VFadeTransition mode="out-in">
<!-- 卡片视图模式 --> <!-- 卡片视图模式 -->
<div v-if="viewType === 'card'" key="card"> <div v-if="viewType === 'card'" key="card">
<div
v-if="progressActive && streamPreviewDataList.length > 0"
class="grid gap-4 grid-torrent-card items-start"
>
<TorrentCard
v-for="(item, index) in streamPreviewDataList"
:key="getTorrentItemKey(item, index)"
:torrent="item"
class="stream-result-item"
/>
</div>
<!-- 资源列表 --> <!-- 资源列表 -->
<VInfiniteScroll <VInfiniteScroll
v-else
mode="intersect" mode="intersect"
side="end" side="end"
:items="cardScroll.displayDataList.value" :items="cardScroll.displayDataList.value"
@@ -976,7 +1079,7 @@ onUnmounted(() => {
</div> </div>
</VInfiniteScroll> </VInfiniteScroll>
<!-- 无结果时显示 --> <!-- 无结果时显示 -->
<div v-if="cardScroll.displayDataList.value.length === 0" class="no-results"> <div v-if="!progressActive && cardScroll.displayDataList.value.length === 0" class="no-results">
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" /> <VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div> <div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
</div> </div>
@@ -986,10 +1089,20 @@ onUnmounted(() => {
<div v-else-if="viewType === 'row'" key="row"> <div v-else-if="viewType === 'row'" key="row">
<VCard class="resource-list-container"> <VCard class="resource-list-container">
<!-- 无结果时显示 --> <!-- 无结果时显示 -->
<div v-if="rowScroll.displayDataList.value.length === 0" class="no-results"> <div v-if="!progressActive && rowScroll.displayDataList.value.length === 0" class="no-results">
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" /> <VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div> <div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
</div> </div>
<div v-else-if="progressActive && streamPreviewDataList.length > 0" class="resource-list overflow-visible">
<div
v-for="(item, index) in streamPreviewDataList"
:key="getTorrentItemKey(item, index)"
class="stream-result-item"
>
<TorrentItem :torrent="item" />
<VDivider v-if="index < streamPreviewDataList.length - 1" class="my-2" />
</div>
</div>
<!-- 资源列表 --> <!-- 资源列表 -->
<VInfiniteScroll <VInfiniteScroll
v-else v-else
@@ -1216,10 +1329,10 @@ onUnmounted(() => {
/* 重新搜索按钮 */ /* 重新搜索按钮 */
.refresh-search-btn { .refresh-search-btn {
block-size: 44px !important;
inline-size: 44px !important;
border-radius: 8px !important; border-radius: 8px !important;
background-color: rgba(var(--v-theme-surface-variant), 0.1); background-color: rgba(var(--v-theme-surface-variant), 0.1);
block-size: 44px !important;
inline-size: 44px !important;
} }
/* AI按钮组样式 */ /* AI按钮组样式 */
-1
View File
@@ -48,7 +48,6 @@ const router = createRouter({
path: '/resource', path: '/resource',
component: () => import('../pages/resource.vue'), component: () => import('../pages/resource.vue'),
meta: { meta: {
keepAlive: true,
requiresAuth: true, requiresAuth: true,
}, },
}, },