diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 2ef049b4..fd2ee972 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -1416,6 +1416,7 @@ export default { searching: 'Searching, please wait...', noData: 'No Data', noResourceFound: 'No resources found', + filteredNoResults: 'Found {count} resource(s), but none match the filter rules', searchStreamTimeout: 'The search connection timed out. Please try again later.', searchStreamDisconnected: 'The search connection was interrupted. Please try again later.', aiRecommend: 'AI Recommendation', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 13b98de4..bbe9c4b5 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -1406,6 +1406,7 @@ export default { searching: '正在搜索,请稍候...', noData: '没有数据', noResourceFound: '未搜索到任何资源', + filteredNoResults: '找到 {count} 个资源,但均不符合过滤规则', searchStreamTimeout: '搜索连接长时间无响应,请稍后重试', searchStreamDisconnected: '搜索连接已中断,请稍后重试', aiRecommend: '智能推荐', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 0b5fb135..3e91c858 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -1404,6 +1404,7 @@ export default { searching: '正在搜索,請稍候...', noData: '沒有數據', noResourceFound: '未搜索到任何資源', + filteredNoResults: '找到 {count} 個資源,但均不符合過濾規則', searchStreamTimeout: '搜索連線長時間無回應,請稍後重試', searchStreamDisconnected: '搜索連線已中斷,請稍後重試', aiRecommend: '智能推薦', diff --git a/src/pages/__tests__/resource.spec.ts b/src/pages/__tests__/resource.spec.ts index 7cc72808..07f7e79b 100644 --- a/src/pages/__tests__/resource.spec.ts +++ b/src/pages/__tests__/resource.spec.ts @@ -549,6 +549,54 @@ describe('resource page search flow', () => { expect(screen.queryByText('搜索服务暂不可用')).not.toBeInTheDocument() }) + it('shows a friendly message when all candidate resources are filtered out', async () => { + await renderResource({ + path: '/resource', + query: { keyword: '过滤空结果', result_type: 'torrent' }, + }) + const source = await latestEventSource() + + // 搜索阶段返回候选资源,最终替换事件为空表示全部被过滤规则淘汰 + source.message({ + items: [createTorrent({ title: '候选资源' })], + stage: 'searching', + total_items: 19, + type: 'append', + }) + source.message({ + candidate_items: 19, + items: [], + stage: 'filtered', + total_items: 0, + type: 'replace', + value: 100, + }) + source.message({ type: 'done' }) + source.fail() + + expect(await screen.findByTestId('no-data')).toHaveTextContent('找到 19 个资源,但均不符合过滤规则') + expect(screen.queryByText('未搜索到任何资源')).not.toBeInTheDocument() + }) + + it('shows a friendly message when the done event reports filtered candidates for title search', async () => { + await renderResource({ + path: '/resource', + query: { keyword: '标题过滤空结果', result_type: 'torrent' }, + }) + const source = await latestEventSource() + + // 标题搜索没有 replace 事件,候选数由 done 事件的 candidate_items 携带 + source.message({ + candidate_items: 5, + items: [], + total_items: 0, + type: 'done', + }) + source.fail() + + expect(await screen.findByTestId('no-data')).toHaveTextContent('找到 5 个资源,但均不符合过滤规则') + }) + it('restores the default empty-state message when the fallback request succeeds without results', async () => { mocks.apiGet.mockResolvedValueOnce({ data: [], success: true }) const rendered = await renderResource({ diff --git a/src/pages/resource.vue b/src/pages/resource.vue index bfc4dd7c..95be2e58 100644 --- a/src/pages/resource.vue +++ b/src/pages/resource.vue @@ -355,6 +355,9 @@ const displayResourceCount = computed(() => // 搜索中只显示进度区域,避免结果抬头和进度条同时占用顶部空间。 const showResultHeader = computed(() => isRefreshed.value && !progressActive.value) +// 记录过滤前的候选资源数,用于过滤后无结果时给出友好提示 +let streamCandidateCount = 0 + let pendingStreamItems: Array = [] let pendingSubtitleStreamItems: Array = [] const streamReplaceBatchCollector = new SearchReplaceBatchCollector() @@ -597,6 +600,7 @@ function resetSearchResults() { // 新搜索开始时先回到未完成态,避免上一轮空态在 SSE 返回前抢先显示。 isRefreshed.value = false errorDescription.value = t('resource.noResourceFound') + streamCandidateCount = 0 rawDataList.value = [] rawSubtitleDataList.value = [] originalDataList.value = [] @@ -684,11 +688,29 @@ function appendSubtitleStreamResults(items: SubtitleInfo[]) { scheduleStreamFlush() } +// 更新候选资源数:优先使用后端显式给出的 candidate_items,否则取搜索阶段事件的 total_items +function updateStreamCandidateCount(eventData: { candidate_items?: unknown; stage?: unknown; total_items?: unknown }) { + if (typeof eventData.candidate_items === 'number') { + streamCandidateCount = Math.max(streamCandidateCount, eventData.candidate_items) + } else if (eventData.stage === 'searching' && typeof eventData.total_items === 'number') { + streamCandidateCount = Math.max(streamCandidateCount, eventData.total_items) + } +} + +// 过滤后无结果但存在候选资源时,用友好提示替代默认的“未搜索到任何资源” +function applyFilteredEmptyResultMessage(resultCount: number) { + if (resultCount === 0 && streamCandidateCount > 0) { + errorDescription.value = t('resource.filteredNoResults', { count: streamCandidateCount }) + } +} + // 完整最终结果到达后原子替换资源列表。 function applyFinalStreamResults(items: Context[]) { streamFinalResultApplied = true flushBufferedStreamState() setStreamResults(items) + // 候选全部被过滤规则淘汰时给出友好提示 + applyFilteredEmptyResultMessage(items.length) } // 应用最终字幕搜索结果 @@ -734,6 +756,7 @@ function handleSearchStreamMessage(eventData: { [key: string]: any }) { updateSearchProgress(eventData, completedItems !== null) if (completedItems) applyFinalSubtitleStreamResults(completedItems) } else { + updateStreamCandidateCount(eventData) const completedItems = streamReplaceBatchCollector.append(eventData) updateSearchProgress(eventData, completedItems !== null) if (completedItems) applyFinalStreamResults(completedItems) @@ -759,6 +782,7 @@ function handleSearchStreamMessage(eventData: { [key: string]: any }) { } const items = Array.isArray(eventData.items) ? (eventData.items as Context[]) : [] + updateStreamCandidateCount(eventData) if (eventData.type === 'append') { updateSearchProgress(eventData) appendStreamResults(items) @@ -770,6 +794,10 @@ function handleSearchStreamMessage(eventData: { [key: string]: any }) { applyFinalStreamResults(items) } else { updateSearchProgress(eventData) + // 标题搜索没有 replace 事件,最终结果为空时在此给出友好提示 + if (eventData.type === 'done' && !streamFinalResultApplied) { + applyFilteredEmptyResultMessage(items.length) + } } }