fix(resource): restore cached search results safely

This commit is contained in:
jxxghp
2026-09-05 17:04:51 +08:00
parent 56a0bf687d
commit e359b9654f
7 changed files with 110 additions and 43 deletions
@@ -83,6 +83,27 @@ describe('useTorrentFilter', () => {
expect(filter.totalFilteredCount.value).toBe(2)
})
it('ignores malformed restored contexts and preserves valid original indices', () => {
const filter = useTorrentFilter()
const malformedWithoutTorrent = {
meta_info: createTorrent().meta_info,
title: '被错误响应模型展平的缓存资源',
} as unknown as Context
const malformedWithoutMeta = {
torrent_info: createTorrent({ title: '缺少元数据的缓存资源' }).torrent_info,
} as unknown as Context
const valid = createTorrent({ title: '可恢复资源' })
const contexts = [malformedWithoutTorrent, malformedWithoutMeta, valid]
expect(filter.filterRowData(contexts).map(item => item.torrent_info.title)).toEqual(['可恢复资源'])
expect(filter.getFilteredIndices()).toEqual([2])
expect(filter.totalFilteredCount.value).toBe(1)
expect(filter.filterCardData(contexts).map(item => item.torrent_info.title)).toEqual(['可恢复资源'])
expect(filter.getFilteredIndices()).toEqual([2])
expect(filter.totalFilteredCount.value).toBe(1)
})
it.each([
['default', ['默认小', '默认大']],
['site', ['站点 A', '站点 B']],
+12 -1
View File
@@ -167,6 +167,11 @@ export function useTorrentFilter() {
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
// 搜索结果必须同时包含可展示的元数据和种子信息。
function isRenderableContext(data: Context | null | undefined): data is Context {
return Boolean(data?.meta_info && data?.torrent_info)
}
// 筛选列表视图数据(不分组)
function filterRowData(items: Context[] | undefined): Context[] {
// 重置状态
@@ -184,13 +189,17 @@ export function useTorrentFilter() {
// 首先收集所有过滤选项
items.forEach(data => {
initOptions(data)
if (isRenderableContext(data)) {
initOptions(data)
}
})
// 筛选数据
let filteredData: Context[] = []
items.forEach((data, index) => {
if (!isRenderableContext(data)) return
const { meta_info, torrent_info } = data
if (
match(filterForm.site, torrent_info.site_name) &&
@@ -238,6 +247,8 @@ export function useTorrentFilter() {
const groupMap = new Map<string, GroupedItem[]>()
items.forEach((item, index) => {
if (!isRenderableContext(item)) return
const { torrent_info, meta_info } = item
// init options
initOptions(item)
+1 -1
View File
@@ -1559,7 +1559,7 @@ export default {
emptySearchHint: 'Search again, or adjust the keyword and site scope before retrying.',
emptyStartHint: 'Return home to start a new search.',
filteredEmptyTitle: 'No resources match the current filters',
filteredEmptyHint: '{count} resource(s) were found. Clear the filters to view them.',
filteredEmptyHint: 'Clear the filters to view all search results.',
searchStreamTimeout: 'The search connection timed out. Please try again later.',
searchStreamDisconnected: 'The search connection was interrupted. Please try again later.',
aiRecommend: 'AI Recommendation',
+1 -1
View File
@@ -1538,7 +1538,7 @@ export default {
emptySearchHint: '可以重新搜索,或调整关键词和站点范围后再试。',
emptyStartHint: '返回首页后可通过搜索入口发起新的搜索。',
filteredEmptyTitle: '当前筛选条件没有匹配项',
filteredEmptyHint: '已获取 {count} 个资源,清除筛选后可查看。',
filteredEmptyHint: '清除筛选后可查看全部搜索结果。',
searchStreamTimeout: '搜索连接长时间无响应,请稍后重试',
searchStreamDisconnected: '搜索连接已中断,请稍后重试',
aiRecommend: '智能推荐',
+1 -1
View File
@@ -1510,7 +1510,7 @@ export default {
emptySearchHint: '可以重新搜尋,或調整關鍵詞和站點範圍後再試。',
emptyStartHint: '返回首頁後可通過搜索入口發起新的搜索。',
filteredEmptyTitle: '當前篩選條件沒有匹配項',
filteredEmptyHint: '已獲取 {count} 個資源,清除篩選後可查看。',
filteredEmptyHint: '清除篩選後可查看全部搜索結果。',
searchStreamTimeout: '搜索連線長時間無回應,請稍後重試',
searchStreamDisconnected: '搜索連線已中斷,請稍後重試',
aiRecommend: '智能推薦',
+46 -1
View File
@@ -523,6 +523,47 @@ describe('resource page search flow', () => {
})
})
it('ignores malformed cached entries while restoring valid previous results', async () => {
const malformed = {
meta_info: createTorrent().meta_info,
title: '被错误响应模型展平的缓存资源',
} as unknown as Context
mocks.apiGet.mockResolvedValueOnce({
success: true,
data: {
params: { keyword: '上次关键词', result_type: 'torrent' },
results: [malformed, createTorrent({ title: '重启后恢复的资源' })],
},
})
await renderResource()
expect(await screen.findByText('重启后恢复的资源')).toBeInTheDocument()
expect(screen.getByTestId('torrent-filter-bar')).toBeInTheDocument()
expect(screen.queryByText('被错误响应模型展平的缓存资源')).not.toBeInTheDocument()
})
it('shows a clean empty state when cached results contain no renderable resources', async () => {
const malformed = {
meta_info: createTorrent().meta_info,
title: '被错误响应模型展平的缓存资源',
} as unknown as Context
mocks.apiGet.mockResolvedValueOnce({
success: true,
data: {
params: { keyword: '上次关键词', result_type: 'torrent' },
results: Array.from({ length: 74 }, () => malformed),
},
})
await renderResource()
const emptyState = await screen.findByRole('status', { name: '没有找到匹配的资源' })
expect(emptyState).toHaveTextContent('可以重新搜索,或调整关键词和站点范围后再试。')
expect(emptyState).not.toHaveTextContent('74')
expect(screen.queryByTestId('torrent-filter-bar')).not.toBeInTheDocument()
})
it('migrates a legacy composite media keyword only when restoring local search state', async () => {
localStorage.setItem(
'MP_ResourceSearchParams',
@@ -588,6 +629,7 @@ describe('resource page search flow', () => {
const emptyState = await screen.findByRole('status', { name: '没有找到匹配的资源' })
expect(emptyState).toHaveTextContent('可以重新搜索,或调整关键词和站点范围后再试。')
expect(emptyState).toHaveTextContent('冷门资源')
expect(screen.queryByTestId('torrent-filter-bar')).not.toBeInTheDocument()
await fireEvent.click(within(emptyState).getByRole('button', { name: '重新搜索' }))
@@ -608,12 +650,15 @@ describe('resource page search flow', () => {
await fireEvent.click(screen.getByRole('button', { name: '筛选 Site A' }))
const emptyState = await screen.findByRole('status', { name: '当前筛选条件没有匹配项' })
expect(emptyState).toHaveTextContent('已获取 1 个资源,清除筛选后可查看。')
expect(emptyState).toHaveTextContent('清除筛选后可查看全部搜索结果。')
expect(emptyState).not.toHaveTextContent('1 个资源')
expect(screen.queryByTestId('torrent-filter-bar')).not.toBeInTheDocument()
expect(screen.queryByText('筛选后可恢复资源')).not.toBeInTheDocument()
await fireEvent.click(within(emptyState).getByRole('button', { name: '清除筛选' }))
expect(await screen.findByText('筛选后可恢复资源')).toBeInTheDocument()
expect(screen.getByTestId('torrent-filter-bar')).toBeInTheDocument()
expect(screen.queryByRole('status', { name: '当前筛选条件没有匹配项' })).not.toBeInTheDocument()
})
+28 -38
View File
@@ -1389,30 +1389,18 @@ const hasData = computed(() => {
}
if (viewType.value === 'row') {
return filteredRowDataList.value.length > 0 || rawDataList.value.length > 0
return filteredRowDataList.value.length > 0
} else {
return filteredCardDataList.value.length > 0 || rawDataList.value.length > 0
return filteredCardDataList.value.length > 0
}
})
// 区分原始搜索有数据但当前视图被筛选为空的状态。
const hasVisibleData = computed(() => {
if (isSubtitleSearch.value) {
return progressActive.value
? streamPreviewSubtitleDataList.value.length > 0 || rawSubtitleDataList.value.length > 0
: rawSubtitleDataList.value.length > 0
}
if (progressActive.value) {
return streamPreviewDataList.value.length > 0 || rawDataList.value.length > 0
}
return viewType.value === 'row' ? filteredRowDataList.value.length > 0 : filteredCardDataList.value.length > 0
})
// 空态保留用户可识别的搜索词,不展示内部媒体来源 ID。
const emptySearchQuery = computed(() => title.value.trim() || keyword.value.trim())
// 只有用户实际选择了筛选值时,空态才提供清除筛选操作。
const hasActiveTorrentFilters = computed(() => !isSubtitleSearch.value && torrentFilter.hasActiveFilters())
// 只有存在可重放参数时才提供重新搜索操作。
const hasRefreshableSearch = computed(
() =>
@@ -1428,6 +1416,14 @@ const searchEmptyDescription = computed(() =>
hasRefreshableSearch.value ? t('resource.emptySearchHint') : t('resource.emptyStartHint'),
)
const resultEmptyTitle = computed(() =>
hasActiveTorrentFilters.value ? t('resource.filteredEmptyTitle') : searchEmptyTitle.value,
)
const resultEmptyDescription = computed(() =>
hasActiveTorrentFilters.value ? t('resource.filteredEmptyHint') : searchEmptyDescription.value,
)
// 监听 AI_RECOMMEND_ENABLED 状态和数据加载状态
// 使用 watchEffect 确保计算属性变化时立即响应
watchEffect(() => {
@@ -1645,7 +1641,7 @@ onUnmounted(() => {
<!-- 视图切换区域 -->
<VFadeTransition mode="out-in">
<div v-if="hasVisibleData" :key="viewType">
<div :key="viewType">
<!-- 卡片视图模式 -->
<div v-if="viewType === 'card'">
<div
@@ -1770,22 +1766,6 @@ onUnmounted(() => {
</VCard>
</div>
</div>
<ResourceSearchEmptyState
v-else-if="!progressActive"
key="filtered-empty"
class="resource-page-empty-state"
:title="t('resource.filteredEmptyTitle')"
:description="t('resource.filteredEmptyHint', { count: rawDataList.length })"
:query="emptySearchQuery"
icon="mdi-filter-off-outline"
>
<template #actions>
<VBtn color="primary" variant="tonal" prepend-icon="mdi-filter-off-outline" @click="handleClearAllFilters">
{{ t('torrent.clearFilters') }}
</VBtn>
</template>
</ResourceSearchEmptyState>
</VFadeTransition>
</div>
@@ -1793,13 +1773,23 @@ onUnmounted(() => {
<ResourceSearchEmptyState
v-else-if="isRefreshed && !progressActive"
class="resource-page-empty-state"
:title="searchEmptyTitle"
:description="searchEmptyDescription"
:title="resultEmptyTitle"
:description="resultEmptyDescription"
:query="emptySearchQuery"
:icon="hasActiveTorrentFilters ? 'mdi-filter-off-outline' : 'mdi-database-search-outline'"
>
<template #actions>
<VBtn
v-if="hasRefreshableSearch"
v-if="hasActiveTorrentFilters"
color="primary"
variant="tonal"
prepend-icon="mdi-filter-off-outline"
@click="handleClearAllFilters"
>
{{ t('torrent.clearFilters') }}
</VBtn>
<VBtn
v-else-if="hasRefreshableSearch"
color="primary"
variant="tonal"
prepend-icon="mdi-refresh"
@@ -1809,7 +1799,7 @@ onUnmounted(() => {
>
{{ t('resource.refreshSearch') }}
</VBtn>
<VBtn variant="text" color="default" prepend-icon="mdi-home-outline" to="/">
<VBtn v-if="!hasActiveTorrentFilters" variant="text" color="default" prepend-icon="mdi-home-outline" to="/">
{{ t('resource.backToHome') }}
</VBtn>
</template>