mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
fix(resource): restore cached search results safely
This commit is contained in:
@@ -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']],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1538,7 +1538,7 @@ export default {
|
||||
emptySearchHint: '可以重新搜索,或调整关键词和站点范围后再试。',
|
||||
emptyStartHint: '返回首页后可通过搜索入口发起新的搜索。',
|
||||
filteredEmptyTitle: '当前筛选条件没有匹配项',
|
||||
filteredEmptyHint: '已获取 {count} 个资源,清除筛选后即可查看。',
|
||||
filteredEmptyHint: '清除筛选后可查看全部搜索结果。',
|
||||
searchStreamTimeout: '搜索连接长时间无响应,请稍后重试',
|
||||
searchStreamDisconnected: '搜索连接已中断,请稍后重试',
|
||||
aiRecommend: '智能推荐',
|
||||
|
||||
@@ -1510,7 +1510,7 @@ export default {
|
||||
emptySearchHint: '可以重新搜尋,或調整關鍵詞和站點範圍後再試。',
|
||||
emptyStartHint: '返回首頁後可通過搜索入口發起新的搜索。',
|
||||
filteredEmptyTitle: '當前篩選條件沒有匹配項',
|
||||
filteredEmptyHint: '已獲取 {count} 個資源,清除篩選後即可查看。',
|
||||
filteredEmptyHint: '清除篩選後可查看全部搜索結果。',
|
||||
searchStreamTimeout: '搜索連線長時間無回應,請稍後重試',
|
||||
searchStreamDisconnected: '搜索連線已中斷,請稍後重試',
|
||||
aiRecommend: '智能推薦',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user