mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +08:00
feat(resource): improve empty search state
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -1556,6 +1556,10 @@ export default {
|
||||
noData: 'No Data',
|
||||
noResourceFound: 'No resources found',
|
||||
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.',
|
||||
searchStreamDisconnected: 'The search connection was interrupted. Please try again later.',
|
||||
aiRecommend: 'AI Recommendation',
|
||||
|
||||
@@ -1535,6 +1535,10 @@ export default {
|
||||
noData: '没有数据',
|
||||
noResourceFound: '未搜索到任何资源',
|
||||
filteredNoResults: '找到 {count} 个资源,但均不符合过滤规则',
|
||||
emptySearchHint: '可以重新搜索,或调整关键词和站点范围后再试。',
|
||||
emptyStartHint: '返回首页后可通过搜索入口发起新的搜索。',
|
||||
filteredEmptyTitle: '当前筛选条件没有匹配项',
|
||||
filteredEmptyHint: '已获取 {count} 个资源,清除筛选后即可查看。',
|
||||
searchStreamTimeout: '搜索连接长时间无响应,请稍后重试',
|
||||
searchStreamDisconnected: '搜索连接已中断,请稍后重试',
|
||||
aiRecommend: '智能推荐',
|
||||
|
||||
@@ -1507,6 +1507,10 @@ export default {
|
||||
noData: '沒有數據',
|
||||
noResourceFound: '未搜索到任何資源',
|
||||
filteredNoResults: '找到 {count} 個資源,但均不符合過濾規則',
|
||||
emptySearchHint: '可以重新搜尋,或調整關鍵詞和站點範圍後再試。',
|
||||
emptyStartHint: '返回首頁後可通過搜索入口發起新的搜索。',
|
||||
filteredEmptyTitle: '當前篩選條件沒有匹配項',
|
||||
filteredEmptyHint: '已獲取 {count} 個資源,清除篩選後即可查看。',
|
||||
searchStreamTimeout: '搜索連線長時間無回應,請稍後重試',
|
||||
searchStreamDisconnected: '搜索連線已中斷,請稍後重試',
|
||||
aiRecommend: '智能推薦',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Context, SubtitleInfo } from '@/api/types'
|
||||
import ResourcePage from '@/pages/resource.vue'
|
||||
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 { defineComponent, nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -208,12 +208,14 @@ const TorrentFilterBarStub = defineComponent({
|
||||
`,
|
||||
})
|
||||
|
||||
const NoDataFoundStub = defineComponent({
|
||||
const ResourceSearchEmptyStateStub = defineComponent({
|
||||
props: {
|
||||
errorDescription: { type: String, default: '' },
|
||||
errorTitle: { type: String, default: '' },
|
||||
description: { 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({
|
||||
@@ -233,8 +235,8 @@ const RefreshButtonStub = defineComponent({
|
||||
const pageStubs = {
|
||||
IconBtn: RefreshButtonStub,
|
||||
LoadingBanner: PassThroughStub,
|
||||
NoDataFound: NoDataFoundStub,
|
||||
ProgressiveCardGrid: ProgressiveCardGridStub,
|
||||
ResourceSearchEmptyState: ResourceSearchEmptyStateStub,
|
||||
SubtitleCard: SubtitleCardStub,
|
||||
SubtitleItem: SubtitleItemStub,
|
||||
Teleport: true,
|
||||
@@ -575,6 +577,46 @@ describe('resource page search flow', () => {
|
||||
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([
|
||||
{
|
||||
item: createTorrent({ title: '流式资源预览' }),
|
||||
@@ -625,7 +667,7 @@ describe('resource page search flow', () => {
|
||||
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({}))
|
||||
})
|
||||
|
||||
@@ -640,7 +682,7 @@ describe('resource page search flow', () => {
|
||||
type: 'error',
|
||||
value: 100,
|
||||
})
|
||||
expect(await screen.findByTestId('no-data')).toHaveTextContent('搜索服务暂不可用')
|
||||
expect(await screen.findByTestId('resource-empty-state')).toHaveTextContent('搜索服务暂不可用')
|
||||
|
||||
await rendered.router.push({
|
||||
path: '/resource',
|
||||
@@ -649,7 +691,7 @@ describe('resource page search flow', () => {
|
||||
const emptySource = await latestEventSource(2)
|
||||
finishStream(emptySource, [])
|
||||
|
||||
expect(await screen.findByTestId('no-data')).toHaveTextContent('未搜索到任何资源')
|
||||
expect(await screen.findByTestId('resource-empty-state')).toHaveTextContent('没有找到匹配的资源')
|
||||
expect(screen.queryByText('搜索服务暂不可用')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -678,7 +720,7 @@ describe('resource page search flow', () => {
|
||||
source.message({ type: 'done' })
|
||||
source.fail()
|
||||
|
||||
expect(await screen.findByTestId('no-data')).toHaveTextContent('找到 19 个资源,但均不符合过滤规则')
|
||||
expect(await screen.findByTestId('resource-empty-state')).toHaveTextContent('找到 19 个资源,但均不符合过滤规则')
|
||||
expect(screen.queryByText('未搜索到任何资源')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -698,7 +740,7 @@ describe('resource page search flow', () => {
|
||||
})
|
||||
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 () => {
|
||||
@@ -711,7 +753,7 @@ describe('resource page search flow', () => {
|
||||
|
||||
source.fail()
|
||||
|
||||
expect(await screen.findByTestId('no-data')).toHaveTextContent('未搜索到任何资源')
|
||||
expect(await screen.findByTestId('resource-empty-state')).toHaveTextContent('没有找到匹配的资源')
|
||||
expect(screen.queryByText('搜索连接已断开')).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(rendered.router.currentRoute.value.query).toEqual({}))
|
||||
})
|
||||
@@ -784,7 +826,7 @@ describe('resource page search flow', () => {
|
||||
mocks.apiGet.mockResolvedValueOnce({ data: [], success: true })
|
||||
await mocks.keepAliveRefresh()
|
||||
|
||||
expect(await screen.findByTestId('no-data')).toHaveTextContent('未搜索到任何资源')
|
||||
expect(await screen.findByTestId('resource-empty-state')).toHaveTextContent('没有找到匹配的资源')
|
||||
expect(screen.queryByText('静默刷新失败')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1020,7 +1062,7 @@ describe('resource page search flow', () => {
|
||||
})
|
||||
const secondSource = await latestEventSource(2)
|
||||
finishStream(secondSource, [])
|
||||
await waitFor(() => expect(screen.getByTestId('no-data')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByTestId('resource-empty-state')).toBeInTheDocument())
|
||||
|
||||
resolveOldRequest({ message: '过期业务错误', success: false })
|
||||
await flushAsyncWork()
|
||||
|
||||
+181
-144
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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 type { Context, MediaDataSource, SubtitleInfo } from '@/api/types'
|
||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||
@@ -383,9 +383,6 @@ const searchProgressLabel = computed(() =>
|
||||
// 进度未返回前使用不确定态
|
||||
const searchProgressIndeterminate = computed(() => !progressEnabled.value && searchProgressPercent.value <= 0)
|
||||
|
||||
// 错误标题
|
||||
const errorTitle = ref(t('resource.noData'))
|
||||
|
||||
// 错误描述
|
||||
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 状态和数据加载状态
|
||||
// 使用 watchEffect 确保计算属性变化时立即响应
|
||||
watchEffect(() => {
|
||||
@@ -1615,163 +1645,175 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 视图切换区域 -->
|
||||
<VFadeTransition mode="out-in">
|
||||
<!-- 卡片视图模式 -->
|
||||
<div v-if="viewType === 'card'" key="card">
|
||||
<div
|
||||
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 }">
|
||||
<div v-if="hasVisibleData" :key="viewType">
|
||||
<!-- 卡片视图模式 -->
|
||||
<div v-if="viewType === 'card'">
|
||||
<div
|
||||
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"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<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"
|
||||
:media-source="activeSearchParams.media_source || undefined"
|
||||
:media-id="activeSearchParams.media_id || undefined"
|
||||
/>
|
||||
<VDivider v-if="index < streamPreviewSubtitleDataList.length - 1" class="my-2" />
|
||||
</div>
|
||||
</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>
|
||||
<div v-else-if="isSubtitleSearch && rawSubtitleDataList.length > 0" class="resource-list">
|
||||
<ProgressiveCardGrid
|
||||
:items="rawSubtitleDataList"
|
||||
:columns="1"
|
||||
:gap="8"
|
||||
:estimated-item-height="190"
|
||||
:overscan-rows="6"
|
||||
:get-item-key="getSubtitleItemKey"
|
||||
<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>
|
||||
|
||||
<!-- 列表视图模式 -->
|
||||
<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
|
||||
: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"
|
||||
>
|
||||
<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" />
|
||||
<VDivider v-if="index < streamPreviewSubtitleDataList.length - 1" class="my-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!isSubtitleSearch && filteredRowDataList.length > 0" class="resource-list">
|
||||
<ProgressiveCardGrid
|
||||
:items="filteredRowDataList"
|
||||
:columns="1"
|
||||
:gap="8"
|
||||
:estimated-item-height="240"
|
||||
:overscan-rows="6"
|
||||
:get-item-key="getTorrentItemKey"
|
||||
<div v-else-if="isSubtitleSearch && rawSubtitleDataList.length > 0" class="resource-list">
|
||||
<ProgressiveCardGrid
|
||||
:items="rawSubtitleDataList"
|
||||
:columns="1"
|
||||
:gap="8"
|
||||
:estimated-item-height="190"
|
||||
:overscan-rows="6"
|
||||
:get-item-key="getSubtitleItemKey"
|
||||
>
|
||||
<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" />
|
||||
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</div>
|
||||
</VCard>
|
||||
<VDivider v-if="index < streamPreviewDataList.length - 1" class="my-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!isSubtitleSearch && filteredRowDataList.length > 0" class="resource-list">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 无数据显示 -->
|
||||
<div v-else-if="isRefreshed && !progressActive" class="d-flex flex-column align-center justify-center py-8">
|
||||
<NoDataFound :errorTitle="errorTitle" :errorDescription="errorDescription" />
|
||||
<VBtn rounded="pill" class="mt-4" color="primary" prepend-icon="mdi-home" to="/">
|
||||
{{ t('resource.backToHome') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<ResourceSearchEmptyState
|
||||
v-else-if="isRefreshed && !progressActive"
|
||||
class="resource-page-empty-state"
|
||||
:title="searchEmptyTitle"
|
||||
:description="searchEmptyDescription"
|
||||
: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" />
|
||||
@@ -1970,13 +2012,8 @@ onUnmounted(() => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 无结果提示 */
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 300px;
|
||||
.resource-page-empty-state {
|
||||
margin-block: 16px;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
|
||||
Reference in New Issue
Block a user