From a10fd121b09aac9b661e16021ddaec306c080f94 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 5 Sep 2026 15:28:22 +0800 Subject: [PATCH] feat(resource): improve empty search state --- .../states/ResourceSearchEmptyState.vue | 163 +++++++++ .../ResourceSearchEmptyState.spec.ts | 24 ++ src/locales/en-US.ts | 4 + src/locales/zh-CN.ts | 4 + src/locales/zh-TW.ts | 4 + src/pages/__tests__/resource.spec.ts | 70 +++- src/pages/resource.vue | 325 ++++++++++-------- 7 files changed, 436 insertions(+), 158 deletions(-) create mode 100644 src/components/states/ResourceSearchEmptyState.vue create mode 100644 src/components/states/__tests__/ResourceSearchEmptyState.spec.ts diff --git a/src/components/states/ResourceSearchEmptyState.vue b/src/components/states/ResourceSearchEmptyState.vue new file mode 100644 index 00000000..18fa9731 --- /dev/null +++ b/src/components/states/ResourceSearchEmptyState.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/src/components/states/__tests__/ResourceSearchEmptyState.spec.ts b/src/components/states/__tests__/ResourceSearchEmptyState.spec.ts new file mode 100644 index 00000000..b3609262 --- /dev/null +++ b/src/components/states/__tests__/ResourceSearchEmptyState.spec.ts @@ -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: '', + }, + }) + + const status = screen.getByRole('status', { name: '没有找到匹配的资源' }) + expect(status).toHaveTextContent('调整筛选条件后再试。') + expect(status).toHaveTextContent('示例搜索词') + expect(within(status).getByRole('button', { name: '重新搜索' })).toBeInTheDocument() + }) +}) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 7525afdd..f187c2cc 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -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', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index f44097d1..1be03ec2 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -1535,6 +1535,10 @@ export default { noData: '没有数据', noResourceFound: '未搜索到任何资源', filteredNoResults: '找到 {count} 个资源,但均不符合过滤规则', + emptySearchHint: '可以重新搜索,或调整关键词和站点范围后再试。', + emptyStartHint: '返回首页后可通过搜索入口发起新的搜索。', + filteredEmptyTitle: '当前筛选条件没有匹配项', + filteredEmptyHint: '已获取 {count} 个资源,清除筛选后即可查看。', searchStreamTimeout: '搜索连接长时间无响应,请稍后重试', searchStreamDisconnected: '搜索连接已中断,请稍后重试', aiRecommend: '智能推荐', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 446ebcc6..97d857c0 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -1507,6 +1507,10 @@ export default { noData: '沒有數據', noResourceFound: '未搜索到任何資源', filteredNoResults: '找到 {count} 個資源,但均不符合過濾規則', + emptySearchHint: '可以重新搜尋,或調整關鍵詞和站點範圍後再試。', + emptyStartHint: '返回首頁後可通過搜索入口發起新的搜索。', + filteredEmptyTitle: '當前篩選條件沒有匹配項', + filteredEmptyHint: '已獲取 {count} 個資源,清除篩選後即可查看。', searchStreamTimeout: '搜索連線長時間無回應,請稍後重試', searchStreamDisconnected: '搜索連線已中斷,請稍後重試', aiRecommend: '智能推薦', diff --git a/src/pages/__tests__/resource.spec.ts b/src/pages/__tests__/resource.spec.ts index 26d8d5e6..ade0a80e 100644 --- a/src/pages/__tests__/resource.spec.ts +++ b/src/pages/__tests__/resource.spec.ts @@ -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: '
{{ errorTitle }} {{ errorDescription }}
', + template: + '
{{ title }} {{ description }} {{ query }}
', }) 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() diff --git a/src/pages/resource.vue b/src/pages/resource.vue index 37b456ec..779f27c0 100644 --- a/src/pages/resource.vue +++ b/src/pages/resource.vue @@ -1,6 +1,6 @@