fix(resource): 候选资源全部被过滤时显示友好提示而非 404 空态

搜索流追踪过滤前候选资源数(candidate_items 或搜索阶段 total_items),最终结果为空且存在候选时显示"找到 N 个资源,但均不符合过滤规则"。Closes #646
This commit is contained in:
jxxghp
2026-08-10 21:41:19 +08:00
parent 70d6bd1f52
commit b8e454114a
5 changed files with 79 additions and 0 deletions
+1
View File
@@ -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',
+1
View File
@@ -1406,6 +1406,7 @@ export default {
searching: '正在搜索,请稍候...',
noData: '没有数据',
noResourceFound: '未搜索到任何资源',
filteredNoResults: '找到 {count} 个资源,但均不符合过滤规则',
searchStreamTimeout: '搜索连接长时间无响应,请稍后重试',
searchStreamDisconnected: '搜索连接已中断,请稍后重试',
aiRecommend: '智能推荐',
+1
View File
@@ -1404,6 +1404,7 @@ export default {
searching: '正在搜索,請稍候...',
noData: '沒有數據',
noResourceFound: '未搜索到任何資源',
filteredNoResults: '找到 {count} 個資源,但均不符合過濾規則',
searchStreamTimeout: '搜索連線長時間無回應,請稍後重試',
searchStreamDisconnected: '搜索連線已中斷,請稍後重試',
aiRecommend: '智能推薦',
+48
View File
@@ -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({
+28
View File
@@ -355,6 +355,9 @@ const displayResourceCount = computed(() =>
//
const showResultHeader = computed(() => isRefreshed.value && !progressActive.value)
//
let streamCandidateCount = 0
let pendingStreamItems: Array<Context> = []
let pendingSubtitleStreamItems: Array<SubtitleInfo> = []
const streamReplaceBatchCollector = new SearchReplaceBatchCollector<Context>()
@@ -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)
}
}
}