mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 09:46:44 +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)
|
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([
|
it.each([
|
||||||
['default', ['默认小', '默认大']],
|
['default', ['默认小', '默认大']],
|
||||||
['site', ['站点 A', '站点 B']],
|
['site', ['站点 A', '站点 B']],
|
||||||
|
|||||||
@@ -167,6 +167,11 @@ export function useTorrentFilter() {
|
|||||||
const match = (filter: Array<string>, value: string | undefined) =>
|
const match = (filter: Array<string>, value: string | undefined) =>
|
||||||
filter.length === 0 || (value && filter.includes(value))
|
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[] {
|
function filterRowData(items: Context[] | undefined): Context[] {
|
||||||
// 重置状态
|
// 重置状态
|
||||||
@@ -184,13 +189,17 @@ export function useTorrentFilter() {
|
|||||||
|
|
||||||
// 首先收集所有过滤选项
|
// 首先收集所有过滤选项
|
||||||
items.forEach(data => {
|
items.forEach(data => {
|
||||||
initOptions(data)
|
if (isRenderableContext(data)) {
|
||||||
|
initOptions(data)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 筛选数据
|
// 筛选数据
|
||||||
let filteredData: Context[] = []
|
let filteredData: Context[] = []
|
||||||
|
|
||||||
items.forEach((data, index) => {
|
items.forEach((data, index) => {
|
||||||
|
if (!isRenderableContext(data)) return
|
||||||
|
|
||||||
const { meta_info, torrent_info } = data
|
const { meta_info, torrent_info } = data
|
||||||
if (
|
if (
|
||||||
match(filterForm.site, torrent_info.site_name) &&
|
match(filterForm.site, torrent_info.site_name) &&
|
||||||
@@ -238,6 +247,8 @@ export function useTorrentFilter() {
|
|||||||
const groupMap = new Map<string, GroupedItem[]>()
|
const groupMap = new Map<string, GroupedItem[]>()
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
|
if (!isRenderableContext(item)) return
|
||||||
|
|
||||||
const { torrent_info, meta_info } = item
|
const { torrent_info, meta_info } = item
|
||||||
// init options
|
// init options
|
||||||
initOptions(item)
|
initOptions(item)
|
||||||
|
|||||||
@@ -1559,7 +1559,7 @@ export default {
|
|||||||
emptySearchHint: 'Search again, or adjust the keyword and site scope before retrying.',
|
emptySearchHint: 'Search again, or adjust the keyword and site scope before retrying.',
|
||||||
emptyStartHint: 'Return home to start a new search.',
|
emptyStartHint: 'Return home to start a new search.',
|
||||||
filteredEmptyTitle: 'No resources match the current filters',
|
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.',
|
searchStreamTimeout: 'The search connection timed out. Please try again later.',
|
||||||
searchStreamDisconnected: 'The search connection was interrupted. Please try again later.',
|
searchStreamDisconnected: 'The search connection was interrupted. Please try again later.',
|
||||||
aiRecommend: 'AI Recommendation',
|
aiRecommend: 'AI Recommendation',
|
||||||
|
|||||||
@@ -1538,7 +1538,7 @@ export default {
|
|||||||
emptySearchHint: '可以重新搜索,或调整关键词和站点范围后再试。',
|
emptySearchHint: '可以重新搜索,或调整关键词和站点范围后再试。',
|
||||||
emptyStartHint: '返回首页后可通过搜索入口发起新的搜索。',
|
emptyStartHint: '返回首页后可通过搜索入口发起新的搜索。',
|
||||||
filteredEmptyTitle: '当前筛选条件没有匹配项',
|
filteredEmptyTitle: '当前筛选条件没有匹配项',
|
||||||
filteredEmptyHint: '已获取 {count} 个资源,清除筛选后即可查看。',
|
filteredEmptyHint: '清除筛选后可查看全部搜索结果。',
|
||||||
searchStreamTimeout: '搜索连接长时间无响应,请稍后重试',
|
searchStreamTimeout: '搜索连接长时间无响应,请稍后重试',
|
||||||
searchStreamDisconnected: '搜索连接已中断,请稍后重试',
|
searchStreamDisconnected: '搜索连接已中断,请稍后重试',
|
||||||
aiRecommend: '智能推荐',
|
aiRecommend: '智能推荐',
|
||||||
|
|||||||
@@ -1510,7 +1510,7 @@ export default {
|
|||||||
emptySearchHint: '可以重新搜尋,或調整關鍵詞和站點範圍後再試。',
|
emptySearchHint: '可以重新搜尋,或調整關鍵詞和站點範圍後再試。',
|
||||||
emptyStartHint: '返回首頁後可通過搜索入口發起新的搜索。',
|
emptyStartHint: '返回首頁後可通過搜索入口發起新的搜索。',
|
||||||
filteredEmptyTitle: '當前篩選條件沒有匹配項',
|
filteredEmptyTitle: '當前篩選條件沒有匹配項',
|
||||||
filteredEmptyHint: '已獲取 {count} 個資源,清除篩選後即可查看。',
|
filteredEmptyHint: '清除篩選後可查看全部搜索結果。',
|
||||||
searchStreamTimeout: '搜索連線長時間無回應,請稍後重試',
|
searchStreamTimeout: '搜索連線長時間無回應,請稍後重試',
|
||||||
searchStreamDisconnected: '搜索連線已中斷,請稍後重試',
|
searchStreamDisconnected: '搜索連線已中斷,請稍後重試',
|
||||||
aiRecommend: '智能推薦',
|
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 () => {
|
it('migrates a legacy composite media keyword only when restoring local search state', async () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'MP_ResourceSearchParams',
|
'MP_ResourceSearchParams',
|
||||||
@@ -588,6 +629,7 @@ describe('resource page search flow', () => {
|
|||||||
const emptyState = await screen.findByRole('status', { name: '没有找到匹配的资源' })
|
const emptyState = await screen.findByRole('status', { name: '没有找到匹配的资源' })
|
||||||
expect(emptyState).toHaveTextContent('可以重新搜索,或调整关键词和站点范围后再试。')
|
expect(emptyState).toHaveTextContent('可以重新搜索,或调整关键词和站点范围后再试。')
|
||||||
expect(emptyState).toHaveTextContent('冷门资源')
|
expect(emptyState).toHaveTextContent('冷门资源')
|
||||||
|
expect(screen.queryByTestId('torrent-filter-bar')).not.toBeInTheDocument()
|
||||||
|
|
||||||
await fireEvent.click(within(emptyState).getByRole('button', { name: '重新搜索' }))
|
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' }))
|
await fireEvent.click(screen.getByRole('button', { name: '筛选 Site A' }))
|
||||||
|
|
||||||
const emptyState = await screen.findByRole('status', { name: '当前筛选条件没有匹配项' })
|
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()
|
expect(screen.queryByText('筛选后可恢复资源')).not.toBeInTheDocument()
|
||||||
|
|
||||||
await fireEvent.click(within(emptyState).getByRole('button', { name: '清除筛选' }))
|
await fireEvent.click(within(emptyState).getByRole('button', { name: '清除筛选' }))
|
||||||
|
|
||||||
expect(await screen.findByText('筛选后可恢复资源')).toBeInTheDocument()
|
expect(await screen.findByText('筛选后可恢复资源')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('torrent-filter-bar')).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('status', { name: '当前筛选条件没有匹配项' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('status', { name: '当前筛选条件没有匹配项' })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+28
-38
@@ -1389,30 +1389,18 @@ const hasData = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (viewType.value === 'row') {
|
if (viewType.value === 'row') {
|
||||||
return filteredRowDataList.value.length > 0 || rawDataList.value.length > 0
|
return filteredRowDataList.value.length > 0
|
||||||
} else {
|
} 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。
|
// 空态保留用户可识别的搜索词,不展示内部媒体来源 ID。
|
||||||
const emptySearchQuery = computed(() => title.value.trim() || keyword.value.trim())
|
const emptySearchQuery = computed(() => title.value.trim() || keyword.value.trim())
|
||||||
|
|
||||||
|
// 只有用户实际选择了筛选值时,空态才提供清除筛选操作。
|
||||||
|
const hasActiveTorrentFilters = computed(() => !isSubtitleSearch.value && torrentFilter.hasActiveFilters())
|
||||||
|
|
||||||
// 只有存在可重放参数时才提供重新搜索操作。
|
// 只有存在可重放参数时才提供重新搜索操作。
|
||||||
const hasRefreshableSearch = computed(
|
const hasRefreshableSearch = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -1428,6 +1416,14 @@ const searchEmptyDescription = computed(() =>
|
|||||||
hasRefreshableSearch.value ? t('resource.emptySearchHint') : t('resource.emptyStartHint'),
|
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 状态和数据加载状态
|
// 监听 AI_RECOMMEND_ENABLED 状态和数据加载状态
|
||||||
// 使用 watchEffect 确保计算属性变化时立即响应
|
// 使用 watchEffect 确保计算属性变化时立即响应
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
@@ -1645,7 +1641,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- 视图切换区域 -->
|
<!-- 视图切换区域 -->
|
||||||
<VFadeTransition mode="out-in">
|
<VFadeTransition mode="out-in">
|
||||||
<div v-if="hasVisibleData" :key="viewType">
|
<div :key="viewType">
|
||||||
<!-- 卡片视图模式 -->
|
<!-- 卡片视图模式 -->
|
||||||
<div v-if="viewType === 'card'">
|
<div v-if="viewType === 'card'">
|
||||||
<div
|
<div
|
||||||
@@ -1770,22 +1766,6 @@ onUnmounted(() => {
|
|||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</VFadeTransition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1793,13 +1773,23 @@ onUnmounted(() => {
|
|||||||
<ResourceSearchEmptyState
|
<ResourceSearchEmptyState
|
||||||
v-else-if="isRefreshed && !progressActive"
|
v-else-if="isRefreshed && !progressActive"
|
||||||
class="resource-page-empty-state"
|
class="resource-page-empty-state"
|
||||||
:title="searchEmptyTitle"
|
:title="resultEmptyTitle"
|
||||||
:description="searchEmptyDescription"
|
:description="resultEmptyDescription"
|
||||||
:query="emptySearchQuery"
|
:query="emptySearchQuery"
|
||||||
|
:icon="hasActiveTorrentFilters ? 'mdi-filter-off-outline' : 'mdi-database-search-outline'"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<VBtn
|
<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"
|
color="primary"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
prepend-icon="mdi-refresh"
|
prepend-icon="mdi-refresh"
|
||||||
@@ -1809,7 +1799,7 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
{{ t('resource.refreshSearch') }}
|
{{ t('resource.refreshSearch') }}
|
||||||
</VBtn>
|
</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') }}
|
{{ t('resource.backToHome') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user