feat(resource): improve empty search state

This commit is contained in:
jxxghp
2026-09-05 15:28:55 +08:00
parent 775977a378
commit a10fd121b0
7 changed files with 436 additions and 158 deletions
@@ -0,0 +1,163 @@
<script setup lang="ts">
interface Props {
title: string
description: string
icon?: string
query?: string
}
withDefaults(defineProps<Props>(), {
icon: 'mdi-database-search-outline',
query: '',
})
</script>
<template>
<section class="resource-search-empty-state" role="status" aria-live="polite" :aria-label="title">
<div class="resource-search-empty-state__visual" aria-hidden="true">
<VIcon :icon="icon" size="38" />
</div>
<div class="resource-search-empty-state__copy">
<h2 class="resource-search-empty-state__title">{{ title }}</h2>
<p class="resource-search-empty-state__description">{{ description }}</p>
<div v-if="query" class="resource-search-empty-state__query">
<VIcon icon="mdi-magnify" size="16" aria-hidden="true" />
<span>{{ query }}</span>
</div>
</div>
<div v-if="$slots.actions" class="resource-search-empty-state__actions">
<slot name="actions" />
</div>
</section>
</template>
<style scoped>
.resource-search-empty-state {
display: grid;
align-content: center;
justify-items: center;
border: var(--app-grouped-list-border);
border-radius: 8px;
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background-color: rgba(var(--v-theme-surface), 0.68);
background-image: var(--glass-sheen, none);
box-shadow: var(--app-surface-shadow, none);
gap: 20px;
inline-size: min(100%, 720px);
margin-inline: auto;
min-block-size: clamp(280px, 38vh, 380px);
padding: clamp(28px, 5vw, 48px);
text-align: center;
animation: resource-empty-state-enter 0.24s ease-out both;
}
.resource-search-empty-state__visual {
display: grid;
place-items: center;
border: 1px solid rgba(var(--v-theme-primary), 0.24);
border-radius: 8px;
background: rgba(var(--v-theme-primary), 0.1);
block-size: 76px;
color: rgb(var(--v-theme-primary));
inline-size: 76px;
box-shadow: inset 0 1px 0 rgba(var(--v-theme-on-surface), 0.1);
}
.resource-search-empty-state__copy {
display: grid;
justify-items: center;
gap: 10px;
max-inline-size: 520px;
}
.resource-search-empty-state__title {
color: rgb(var(--v-theme-on-surface));
font-size: 1.25rem;
font-weight: 650;
line-height: 1.4;
margin: 0;
}
.resource-search-empty-state__description {
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: 0.95rem;
line-height: 1.7;
margin: 0;
}
.resource-search-empty-state__query {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 6px;
background: rgba(var(--v-theme-on-surface), 0.05);
color: rgba(var(--v-theme-on-surface), 0.78);
font-size: 0.8125rem;
line-height: 1.5;
margin-block-start: 4px;
max-inline-size: min(100%, 420px);
padding-block: 7px;
padding-inline: 11px;
}
.resource-search-empty-state__query span {
overflow-wrap: anywhere;
}
.resource-search-empty-state__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
@keyframes resource-empty-state-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
@media (width <= 600px) {
.resource-search-empty-state {
gap: 18px;
min-block-size: 280px;
padding-block: 32px;
padding-inline: 20px;
}
.resource-search-empty-state__visual {
block-size: 68px;
inline-size: 68px;
}
.resource-search-empty-state__title {
font-size: 1.125rem;
}
.resource-search-empty-state__actions {
inline-size: 100%;
}
.resource-search-empty-state__actions :deep(.v-btn) {
flex: 1 1 140px;
}
}
@media (prefers-reduced-motion: reduce) {
.resource-search-empty-state {
animation: none;
}
}
</style>
@@ -0,0 +1,24 @@
import ResourceSearchEmptyState from '@/components/states/ResourceSearchEmptyState.vue'
import { screen, within } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { describe, expect, it } from 'vitest'
describe('ResourceSearchEmptyState', () => {
it('presents the result context and keeps action controls inside the status region', async () => {
await renderWithProviders(ResourceSearchEmptyState, {
props: {
description: '调整筛选条件后再试。',
query: '示例搜索词',
title: '没有找到匹配的资源',
},
slots: {
actions: '<button type="button">重新搜索</button>',
},
})
const status = screen.getByRole('status', { name: '没有找到匹配的资源' })
expect(status).toHaveTextContent('调整筛选条件后再试。')
expect(status).toHaveTextContent('示例搜索词')
expect(within(status).getByRole('button', { name: '重新搜索' })).toBeInTheDocument()
})
})
+4
View File
@@ -1556,6 +1556,10 @@ export default {
noData: 'No Data', noData: 'No Data',
noResourceFound: 'No resources found', noResourceFound: 'No resources found',
filteredNoResults: 'Found {count} resource(s), but none match the filter rules', filteredNoResults: 'Found {count} resource(s), but none match the filter rules',
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.',
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',
+4
View File
@@ -1535,6 +1535,10 @@ export default {
noData: '没有数据', noData: '没有数据',
noResourceFound: '未搜索到任何资源', noResourceFound: '未搜索到任何资源',
filteredNoResults: '找到 {count} 个资源,但均不符合过滤规则', filteredNoResults: '找到 {count} 个资源,但均不符合过滤规则',
emptySearchHint: '可以重新搜索,或调整关键词和站点范围后再试。',
emptyStartHint: '返回首页后可通过搜索入口发起新的搜索。',
filteredEmptyTitle: '当前筛选条件没有匹配项',
filteredEmptyHint: '已获取 {count} 个资源,清除筛选后即可查看。',
searchStreamTimeout: '搜索连接长时间无响应,请稍后重试', searchStreamTimeout: '搜索连接长时间无响应,请稍后重试',
searchStreamDisconnected: '搜索连接已中断,请稍后重试', searchStreamDisconnected: '搜索连接已中断,请稍后重试',
aiRecommend: '智能推荐', aiRecommend: '智能推荐',
+4
View File
@@ -1507,6 +1507,10 @@ export default {
noData: '沒有數據', noData: '沒有數據',
noResourceFound: '未搜索到任何資源', noResourceFound: '未搜索到任何資源',
filteredNoResults: '找到 {count} 個資源,但均不符合過濾規則', filteredNoResults: '找到 {count} 個資源,但均不符合過濾規則',
emptySearchHint: '可以重新搜尋,或調整關鍵詞和站點範圍後再試。',
emptyStartHint: '返回首頁後可通過搜索入口發起新的搜索。',
filteredEmptyTitle: '當前篩選條件沒有匹配項',
filteredEmptyHint: '已獲取 {count} 個資源,清除篩選後即可查看。',
searchStreamTimeout: '搜索連線長時間無回應,請稍後重試', searchStreamTimeout: '搜索連線長時間無回應,請稍後重試',
searchStreamDisconnected: '搜索連線已中斷,請稍後重試', searchStreamDisconnected: '搜索連線已中斷,請稍後重試',
aiRecommend: '智能推薦', aiRecommend: '智能推薦',
+56 -14
View File
@@ -1,7 +1,7 @@
import type { Context, SubtitleInfo } from '@/api/types' import type { Context, SubtitleInfo } from '@/api/types'
import ResourcePage from '@/pages/resource.vue' import ResourcePage from '@/pages/resource.vue'
import { DEFAULT_PERMISSIONS } from '@/utils/permission' import { DEFAULT_PERMISSIONS } from '@/utils/permission'
import { fireEvent, screen, waitFor } from '@testing-library/vue' import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render' import { renderWithProviders } from '@tests/support/render'
import { defineComponent, nextTick } from 'vue' import { defineComponent, nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -208,12 +208,14 @@ const TorrentFilterBarStub = defineComponent({
`, `,
}) })
const NoDataFoundStub = defineComponent({ const ResourceSearchEmptyStateStub = defineComponent({
props: { props: {
errorDescription: { type: String, default: '' }, description: { type: String, default: '' },
errorTitle: { type: String, default: '' }, query: { type: String, default: '' },
title: { type: String, default: '' },
}, },
template: '<section data-testid="no-data">{{ errorTitle }} {{ errorDescription }}</section>', template:
'<section role="status" :aria-label="title" data-testid="resource-empty-state">{{ title }} {{ description }} {{ query }}<slot name="actions" /></section>',
}) })
const PassThroughStub = defineComponent({ const PassThroughStub = defineComponent({
@@ -233,8 +235,8 @@ const RefreshButtonStub = defineComponent({
const pageStubs = { const pageStubs = {
IconBtn: RefreshButtonStub, IconBtn: RefreshButtonStub,
LoadingBanner: PassThroughStub, LoadingBanner: PassThroughStub,
NoDataFound: NoDataFoundStub,
ProgressiveCardGrid: ProgressiveCardGridStub, ProgressiveCardGrid: ProgressiveCardGridStub,
ResourceSearchEmptyState: ResourceSearchEmptyStateStub,
SubtitleCard: SubtitleCardStub, SubtitleCard: SubtitleCardStub,
SubtitleItem: SubtitleItemStub, SubtitleItem: SubtitleItemStub,
Teleport: true, Teleport: true,
@@ -575,6 +577,46 @@ describe('resource page search flow', () => {
expect(mocks.apiGet).not.toHaveBeenCalled() expect(mocks.apiGet).not.toHaveBeenCalled()
}) })
it('keeps the search context visible and can retry directly from the empty state', async () => {
await renderResource({
path: '/resource',
query: { keyword: '冷门资源', result_type: 'torrent' },
})
const source = await latestEventSource()
finishStream(source, [])
const emptyState = await screen.findByRole('status', { name: '没有找到匹配的资源' })
expect(emptyState).toHaveTextContent('可以重新搜索,或调整关键词和站点范围后再试。')
expect(emptyState).toHaveTextContent('冷门资源')
await fireEvent.click(within(emptyState).getByRole('button', { name: '重新搜索' }))
const retrySource = await latestEventSource(2)
expect(new URL(retrySource.url).searchParams.get('keyword')).toBe('冷门资源')
finishStream(retrySource, [])
})
it('offers to clear filters when existing resources are hidden by the current filter', async () => {
await renderResource({
path: '/resource',
query: { keyword: '筛选空态', result_type: 'torrent' },
})
const source = await latestEventSource()
finishStream(source, [createTorrent({ site: 'Site B', title: '筛选后可恢复资源' })])
expect(await screen.findByText('筛选后可恢复资源')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '筛选 Site A' }))
const emptyState = await screen.findByRole('status', { name: '当前筛选条件没有匹配项' })
expect(emptyState).toHaveTextContent('已获取 1 个资源,清除筛选后即可查看。')
expect(screen.queryByText('筛选后可恢复资源')).not.toBeInTheDocument()
await fireEvent.click(within(emptyState).getByRole('button', { name: '清除筛选' }))
expect(await screen.findByText('筛选后可恢复资源')).toBeInTheDocument()
expect(screen.queryByRole('status', { name: '当前筛选条件没有匹配项' })).not.toBeInTheDocument()
})
it.each([ it.each([
{ {
item: createTorrent({ title: '流式资源预览' }), item: createTorrent({ title: '流式资源预览' }),
@@ -625,7 +667,7 @@ describe('resource page search flow', () => {
value: 100, value: 100,
}) })
expect(await screen.findByTestId('no-data')).toHaveTextContent('搜索服务暂不可用') expect(await screen.findByTestId('resource-empty-state')).toHaveTextContent('搜索服务暂不可用')
await waitFor(() => expect(rendered.router.currentRoute.value.query).toEqual({})) await waitFor(() => expect(rendered.router.currentRoute.value.query).toEqual({}))
}) })
@@ -640,7 +682,7 @@ describe('resource page search flow', () => {
type: 'error', type: 'error',
value: 100, value: 100,
}) })
expect(await screen.findByTestId('no-data')).toHaveTextContent('搜索服务暂不可用') expect(await screen.findByTestId('resource-empty-state')).toHaveTextContent('搜索服务暂不可用')
await rendered.router.push({ await rendered.router.push({
path: '/resource', path: '/resource',
@@ -649,7 +691,7 @@ describe('resource page search flow', () => {
const emptySource = await latestEventSource(2) const emptySource = await latestEventSource(2)
finishStream(emptySource, []) finishStream(emptySource, [])
expect(await screen.findByTestId('no-data')).toHaveTextContent('未搜索到任何资源') expect(await screen.findByTestId('resource-empty-state')).toHaveTextContent('没有找到匹配的资源')
expect(screen.queryByText('搜索服务暂不可用')).not.toBeInTheDocument() expect(screen.queryByText('搜索服务暂不可用')).not.toBeInTheDocument()
}) })
@@ -678,7 +720,7 @@ describe('resource page search flow', () => {
source.message({ type: 'done' }) source.message({ type: 'done' })
source.fail() source.fail()
expect(await screen.findByTestId('no-data')).toHaveTextContent('找到 19 个资源,但均不符合过滤规则') expect(await screen.findByTestId('resource-empty-state')).toHaveTextContent('找到 19 个资源,但均不符合过滤规则')
expect(screen.queryByText('未搜索到任何资源')).not.toBeInTheDocument() expect(screen.queryByText('未搜索到任何资源')).not.toBeInTheDocument()
}) })
@@ -698,7 +740,7 @@ describe('resource page search flow', () => {
}) })
source.fail() source.fail()
expect(await screen.findByTestId('no-data')).toHaveTextContent('找到 5 个资源,但均不符合过滤规则') expect(await screen.findByTestId('resource-empty-state')).toHaveTextContent('找到 5 个资源,但均不符合过滤规则')
}) })
it('restores the default empty-state message when the fallback request succeeds without results', async () => { it('restores the default empty-state message when the fallback request succeeds without results', async () => {
@@ -711,7 +753,7 @@ describe('resource page search flow', () => {
source.fail() source.fail()
expect(await screen.findByTestId('no-data')).toHaveTextContent('未搜索到任何资源') expect(await screen.findByTestId('resource-empty-state')).toHaveTextContent('没有找到匹配的资源')
expect(screen.queryByText('搜索连接已断开')).not.toBeInTheDocument() expect(screen.queryByText('搜索连接已断开')).not.toBeInTheDocument()
await waitFor(() => expect(rendered.router.currentRoute.value.query).toEqual({})) await waitFor(() => expect(rendered.router.currentRoute.value.query).toEqual({}))
}) })
@@ -784,7 +826,7 @@ describe('resource page search flow', () => {
mocks.apiGet.mockResolvedValueOnce({ data: [], success: true }) mocks.apiGet.mockResolvedValueOnce({ data: [], success: true })
await mocks.keepAliveRefresh() await mocks.keepAliveRefresh()
expect(await screen.findByTestId('no-data')).toHaveTextContent('未搜索到任何资源') expect(await screen.findByTestId('resource-empty-state')).toHaveTextContent('没有找到匹配的资源')
expect(screen.queryByText('静默刷新失败')).not.toBeInTheDocument() expect(screen.queryByText('静默刷新失败')).not.toBeInTheDocument()
}) })
@@ -1020,7 +1062,7 @@ describe('resource page search flow', () => {
}) })
const secondSource = await latestEventSource(2) const secondSource = await latestEventSource(2)
finishStream(secondSource, []) finishStream(secondSource, [])
await waitFor(() => expect(screen.getByTestId('no-data')).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId('resource-empty-state')).toBeInTheDocument())
resolveOldRequest({ message: '过期业务错误', success: false }) resolveOldRequest({ message: '过期业务错误', success: false })
await flushAsyncWork() await flushAsyncWork()
+181 -144
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LocationQuery } from 'vue-router' import type { LocationQuery } from 'vue-router'
import NoDataFound from '@/components/states/NoDataFound.vue' import ResourceSearchEmptyState from '@/components/states/ResourceSearchEmptyState.vue'
import api from '@/api' import api from '@/api'
import type { Context, MediaDataSource, SubtitleInfo } from '@/api/types' import type { Context, MediaDataSource, SubtitleInfo } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue' import TorrentCard from '@/components/cards/TorrentCard.vue'
@@ -383,9 +383,6 @@ const searchProgressLabel = computed(() =>
// 进度未返回前使用不确定态 // 进度未返回前使用不确定态
const searchProgressIndeterminate = computed(() => !progressEnabled.value && searchProgressPercent.value <= 0) const searchProgressIndeterminate = computed(() => !progressEnabled.value && searchProgressPercent.value <= 0)
// 错误标题
const errorTitle = ref(t('resource.noData'))
// 错误描述 // 错误描述
const errorDescription = ref(t('resource.noResourceFound')) const errorDescription = ref(t('resource.noResourceFound'))
@@ -1398,6 +1395,39 @@ const hasData = computed(() => {
} }
}) })
// 区分原始搜索有数据但当前视图被筛选为空的状态。
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 hasRefreshableSearch = computed(
() =>
hasSearchKeyword(activeSearchParams.value) ||
Boolean(lastSearchParams.value && hasSearchKeyword(lastSearchParams.value)),
)
const searchEmptyTitle = computed(() =>
errorDescription.value === t('resource.noResourceFound') ? t('torrent.noResults') : errorDescription.value,
)
const searchEmptyDescription = computed(() =>
hasRefreshableSearch.value ? t('resource.emptySearchHint') : t('resource.emptyStartHint'),
)
// 监听 AI_RECOMMEND_ENABLED 状态和数据加载状态 // 监听 AI_RECOMMEND_ENABLED 状态和数据加载状态
// 使用 watchEffect 确保计算属性变化时立即响应 // 使用 watchEffect 确保计算属性变化时立即响应
watchEffect(() => { watchEffect(() => {
@@ -1615,163 +1645,175 @@ onUnmounted(() => {
<!-- 视图切换区域 --> <!-- 视图切换区域 -->
<VFadeTransition mode="out-in"> <VFadeTransition mode="out-in">
<!-- 卡片视图模式 --> <div v-if="hasVisibleData" :key="viewType">
<div v-if="viewType === 'card'" key="card"> <!-- 卡片视图模式 -->
<div <div v-if="viewType === 'card'">
v-if="isSubtitleSearch && progressActive && streamPreviewSubtitleDataList.length > 0" <div
class="grid gap-4 grid-torrent-card items-start" v-if="isSubtitleSearch && progressActive && streamPreviewSubtitleDataList.length > 0"
> class="grid gap-4 grid-torrent-card items-start"
<SubtitleCard >
v-for="(item, index) in streamPreviewSubtitleDataList"
:key="getSubtitleItemKey(item, index)"
:subtitle="item"
:media-source="activeSearchParams.media_source || undefined"
:media-id="activeSearchParams.media_id || undefined"
class="stream-result-item"
/>
</div>
<ProgressiveCardGrid
v-else-if="isSubtitleSearch && rawSubtitleDataList.length > 0"
:items="rawSubtitleDataList"
:get-item-key="getSubtitleItemKey"
:min-item-width="300"
:estimated-item-height="320"
>
<template #default="{ item }">
<SubtitleCard <SubtitleCard
v-for="(item, index) in streamPreviewSubtitleDataList"
:key="getSubtitleItemKey(item, index)"
:subtitle="item" :subtitle="item"
:media-source="activeSearchParams.media_source || undefined" :media-source="activeSearchParams.media_source || undefined"
:media-id="activeSearchParams.media_id || undefined" :media-id="activeSearchParams.media_id || undefined"
/>
</template>
</ProgressiveCardGrid>
<div
v-else-if="!isSubtitleSearch && progressActive && streamPreviewDataList.length > 0"
class="grid gap-4 grid-torrent-card items-start"
>
<TorrentCard
v-for="(item, index) in streamPreviewDataList"
:key="getTorrentItemKey(item, index)"
:torrent="item"
class="stream-result-item"
/>
</div>
<ProgressiveCardGrid
v-else-if="filteredCardDataList.length > 0"
:items="filteredCardDataList"
:get-item-key="getTorrentItemKey"
:min-item-width="300"
:estimated-item-height="400"
>
<template #default="{ item }">
<TorrentCard :torrent="item" :more="item.more" />
</template>
</ProgressiveCardGrid>
<!-- 无结果时显示 -->
<div
v-if="
!progressActive &&
((isSubtitleSearch && rawSubtitleDataList.length === 0) ||
(!isSubtitleSearch && filteredCardDataList.length === 0))
"
class="no-results"
>
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
</div>
</div>
<!-- 列表视图模式 -->
<div v-else-if="viewType === 'row'" key="row">
<VCard class="resource-list-container">
<!-- 无结果时显示 -->
<div
v-if="
!progressActive &&
((isSubtitleSearch && rawSubtitleDataList.length === 0) ||
(!isSubtitleSearch && filteredRowDataList.length === 0))
"
class="no-results"
>
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
</div>
<div
v-else-if="isSubtitleSearch && progressActive && streamPreviewSubtitleDataList.length > 0"
class="resource-list overflow-visible"
>
<div
v-for="(item, index) in streamPreviewSubtitleDataList"
:key="getSubtitleItemKey(item, index)"
class="stream-result-item" class="stream-result-item"
> />
<SubtitleItem </div>
<ProgressiveCardGrid
v-else-if="isSubtitleSearch && rawSubtitleDataList.length > 0"
:items="rawSubtitleDataList"
:get-item-key="getSubtitleItemKey"
:min-item-width="300"
:estimated-item-height="320"
>
<template #default="{ item }">
<SubtitleCard
:subtitle="item" :subtitle="item"
:media-source="activeSearchParams.media_source || undefined" :media-source="activeSearchParams.media_source || undefined"
:media-id="activeSearchParams.media_id || undefined" :media-id="activeSearchParams.media_id || undefined"
/> />
<VDivider v-if="index < streamPreviewSubtitleDataList.length - 1" class="my-2" /> </template>
</div> </ProgressiveCardGrid>
<div
v-else-if="!isSubtitleSearch && progressActive && streamPreviewDataList.length > 0"
class="grid gap-4 grid-torrent-card items-start"
>
<TorrentCard
v-for="(item, index) in streamPreviewDataList"
:key="getTorrentItemKey(item, index)"
:torrent="item"
class="stream-result-item"
/>
</div> </div>
<div v-else-if="isSubtitleSearch && rawSubtitleDataList.length > 0" class="resource-list"> <ProgressiveCardGrid
<ProgressiveCardGrid v-else-if="filteredCardDataList.length > 0"
:items="rawSubtitleDataList" :items="filteredCardDataList"
:columns="1" :get-item-key="getTorrentItemKey"
:gap="8" :min-item-width="300"
:estimated-item-height="190" :estimated-item-height="400"
:overscan-rows="6" >
:get-item-key="getSubtitleItemKey" <template #default="{ item }">
<TorrentCard :torrent="item" :more="item.more" />
</template>
</ProgressiveCardGrid>
</div>
<!-- 列表视图模式 -->
<div v-else-if="viewType === 'row'">
<VCard class="resource-list-container">
<div
v-if="isSubtitleSearch && progressActive && streamPreviewSubtitleDataList.length > 0"
class="resource-list overflow-visible"
> >
<template #default="{ item, index }"> <div
v-for="(item, index) in streamPreviewSubtitleDataList"
:key="getSubtitleItemKey(item, index)"
class="stream-result-item"
>
<SubtitleItem <SubtitleItem
:subtitle="item" :subtitle="item"
:media-source="activeSearchParams.media_source || undefined" :media-source="activeSearchParams.media_source || undefined"
:media-id="activeSearchParams.media_id || undefined" :media-id="activeSearchParams.media_id || undefined"
/> />
<VDivider v-if="index < rawSubtitleDataList.length - 1" class="my-2" /> <VDivider v-if="index < streamPreviewSubtitleDataList.length - 1" class="my-2" />
</template> </div>
</ProgressiveCardGrid>
</div>
<div
v-else-if="!isSubtitleSearch && progressActive && streamPreviewDataList.length > 0"
class="resource-list overflow-visible"
>
<div
v-for="(item, index) in streamPreviewDataList"
:key="getTorrentItemKey(item, index)"
class="stream-result-item"
>
<TorrentItem :torrent="item" />
<VDivider v-if="index < streamPreviewDataList.length - 1" class="my-2" />
</div> </div>
</div> <div v-else-if="isSubtitleSearch && rawSubtitleDataList.length > 0" class="resource-list">
<div v-else-if="!isSubtitleSearch && filteredRowDataList.length > 0" class="resource-list"> <ProgressiveCardGrid
<ProgressiveCardGrid :items="rawSubtitleDataList"
:items="filteredRowDataList" :columns="1"
:columns="1" :gap="8"
:gap="8" :estimated-item-height="190"
:estimated-item-height="240" :overscan-rows="6"
:overscan-rows="6" :get-item-key="getSubtitleItemKey"
:get-item-key="getTorrentItemKey" >
<template #default="{ item, index }">
<SubtitleItem
:subtitle="item"
:media-source="activeSearchParams.media_source || undefined"
:media-id="activeSearchParams.media_id || undefined"
/>
<VDivider v-if="index < rawSubtitleDataList.length - 1" class="my-2" />
</template>
</ProgressiveCardGrid>
</div>
<div
v-else-if="!isSubtitleSearch && progressActive && streamPreviewDataList.length > 0"
class="resource-list overflow-visible"
> >
<template #default="{ item, index }"> <div
v-for="(item, index) in streamPreviewDataList"
:key="getTorrentItemKey(item, index)"
class="stream-result-item"
>
<TorrentItem :torrent="item" /> <TorrentItem :torrent="item" />
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" /> <VDivider v-if="index < streamPreviewDataList.length - 1" class="my-2" />
</template> </div>
</ProgressiveCardGrid> </div>
</div> <div v-else-if="!isSubtitleSearch && filteredRowDataList.length > 0" class="resource-list">
</VCard> <ProgressiveCardGrid
:items="filteredRowDataList"
:columns="1"
:gap="8"
:estimated-item-height="240"
:overscan-rows="6"
:get-item-key="getTorrentItemKey"
>
<template #default="{ item, index }">
<TorrentItem :torrent="item" />
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
</template>
</ProgressiveCardGrid>
</div>
</VCard>
</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>
<!-- 无数据显示 --> <!-- 无数据显示 -->
<div v-else-if="isRefreshed && !progressActive" class="d-flex flex-column align-center justify-center py-8"> <ResourceSearchEmptyState
<NoDataFound :errorTitle="errorTitle" :errorDescription="errorDescription" /> v-else-if="isRefreshed && !progressActive"
<VBtn rounded="pill" class="mt-4" color="primary" prepend-icon="mdi-home" to="/"> class="resource-page-empty-state"
{{ t('resource.backToHome') }} :title="searchEmptyTitle"
</VBtn> :description="searchEmptyDescription"
</div> :query="emptySearchQuery"
>
<template #actions>
<VBtn
v-if="hasRefreshableSearch"
color="primary"
variant="tonal"
prepend-icon="mdi-refresh"
:loading="isRefreshing"
:disabled="isRefreshing"
@click="refreshSearch"
>
{{ t('resource.refreshSearch') }}
</VBtn>
<VBtn variant="text" color="default" prepend-icon="mdi-home-outline" to="/">
{{ t('resource.backToHome') }}
</VBtn>
</template>
</ResourceSearchEmptyState>
<!-- 初始加载状态 --> <!-- 初始加载状态 -->
<LoadingBanner v-else-if="!isRefreshed && !isSearchLoading" /> <LoadingBanner v-else-if="!isRefreshed && !isSearchLoading" />
@@ -1970,13 +2012,8 @@ onUnmounted(() => {
display: block; display: block;
} }
/* 无结果提示 */ .resource-page-empty-state {
.no-results { margin-block: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-block-size: 300px;
} }
@media (width <= 600px) { @media (width <= 600px) {