mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +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',
|
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',
|
||||||
|
|||||||
@@ -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: '智能推荐',
|
||||||
|
|||||||
@@ -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: '智能推薦',
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user