diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c1259bd3..bd937828 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1020,11 +1020,6 @@ "count": 1 } }, - "src/views/discover/MediaCardListView.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "src/views/discover/MediaCardSlideView.vue": { "@typescript-eslint/no-unused-vars": { "count": 1 diff --git a/src/components/dialog/__tests__/DiscoverTabOrderDialog.spec.ts b/src/components/dialog/__tests__/DiscoverTabOrderDialog.spec.ts new file mode 100644 index 00000000..37b55468 --- /dev/null +++ b/src/components/dialog/__tests__/DiscoverTabOrderDialog.spec.ts @@ -0,0 +1,137 @@ +import DiscoverTabOrderDialog from '@/components/dialog/DiscoverTabOrderDialog.vue' +import type { DiscoverSource } from '@/api/types' +import { screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '@tests/support/render' +import { defineComponent, h, type PropType } from 'vue' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('vuedraggable', async () => { + const { defineComponent, h } = await import('vue') + + return { + default: defineComponent({ + name: 'DraggableStub', + props: { + modelValue: { type: Array as PropType, required: true }, + }, + emits: ['update:modelValue'], + setup(props, { emit, slots }) { + return () => + h('div', [ + h( + 'button', + { + onClick: () => emit('update:modelValue', [...props.modelValue].reverse()), + type: 'button', + }, + '反转顺序', + ), + ...props.modelValue.map(element => slots.item?.({ element })), + ]) + }, + }), + } +}) + +const DialogCloseButtonStub = defineComponent({ + name: 'VDialogCloseBtn', + props: { + modelValue: { type: Boolean, default: true }, + }, + emits: ['update:modelValue'], + setup(_props, { emit }) { + return () => h('button', { onClick: () => emit('update:modelValue', false), type: 'button' }, '关闭') + }, +}) + +function createSource(name: string, prefix: string): DiscoverSource { + return { + api_path: `discover/${prefix}`, + filter_params: { type: prefix }, + filter_ui: [], + mediaid_prefix: prefix, + name, + } +} + +async function renderDialog(tabs: DiscoverSource[]) { + const close = vi.fn() + const save = vi.fn() + const updateModelValue = vi.fn() + const result = await renderWithProviders(DiscoverTabOrderDialog, { + props: { + modelValue: true, + onClose: close, + onSave: save, + 'onUpdate:modelValue': updateModelValue, + tabs, + }, + global: { + components: { + VDialogCloseBtn: DialogCloseButtonStub, + }, + }, + }) + + return { ...result, close, save, updateModelValue } +} + +describe('DiscoverTabOrderDialog', () => { + it('reorders a shallow local copy without mutating the parent tabs', async () => { + const tabs = [createSource('来源甲', 'source-a'), createSource('来源乙', 'source-b')] + const originalOrder = [...tabs] + const user = userEvent.setup() + const { save } = await renderDialog(tabs) + + await user.click(screen.getByRole('button', { name: '反转顺序' })) + await user.click(screen.getByRole('button', { name: '保存' })) + + expect(tabs).toEqual(originalOrder) + expect(save).toHaveBeenCalledOnce() + const savedTabs = save.mock.calls[0][0] as DiscoverSource[] + expect(savedTabs.map(item => item.mediaid_prefix)).toEqual(['source-b', 'source-a']) + expect(savedTabs[0]).not.toBe(tabs[1]) + expect(savedTabs[0].filter_params).toStrictEqual(tabs[1].filter_params) + }) + + it('resets the local order when the tabs prop changes', async () => { + const user = userEvent.setup() + const { rerender, save } = await renderDialog([ + createSource('来源甲', 'source-a'), + createSource('来源乙', 'source-b'), + ]) + await user.click(screen.getByRole('button', { name: '反转顺序' })) + + await rerender({ + modelValue: true, + tabs: [createSource('来源丙', 'source-c'), createSource('来源丁', 'source-d')], + }) + await user.click(screen.getByRole('button', { name: '保存' })) + + const savedTabs = save.mock.calls[0][0] as DiscoverSource[] + expect(savedTabs.map(item => item.mediaid_prefix)).toEqual(['source-c', 'source-d']) + }) + + it('emits both model closure and close from the close control', async () => { + const user = userEvent.setup() + const { close, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')]) + + await user.click(screen.getByRole('button', { name: '关闭' })) + + expect(updateModelValue).toHaveBeenCalledOnce() + expect(updateModelValue).toHaveBeenCalledWith(false) + expect(close).toHaveBeenCalledOnce() + }) + + it('emits only the current local order when saving', async () => { + const user = userEvent.setup() + const { close, save, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')]) + + await user.click(screen.getByRole('button', { name: '保存' })) + + expect(save).toHaveBeenCalledOnce() + expect(close).not.toHaveBeenCalled() + expect(updateModelValue).not.toHaveBeenCalled() + }) +}) diff --git a/src/pages/__tests__/browse.spec.ts b/src/pages/__tests__/browse.spec.ts new file mode 100644 index 00000000..7d982fed --- /dev/null +++ b/src/pages/__tests__/browse.spec.ts @@ -0,0 +1,101 @@ +import BrowsePage from '@/pages/browse.vue' +import { screen } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { defineComponent, h, type PropType } from 'vue' +import { describe, expect, it } from 'vitest' + +const MediaCardListViewStub = defineComponent({ + name: 'MediaCardListView', + props: { + apipath: String, + params: Object as PropType>, + }, + setup(props) { + return () => + h('section', { 'aria-label': '媒体 browse 列表', 'data-api-path': props.apipath }, [ + h('output', { 'aria-label': '媒体 browse 查询' }, JSON.stringify(props.params)), + ]) + }, +}) + +const PersonCardListViewStub = defineComponent({ + name: 'PersonCardListView', + props: { + apipath: String, + params: Object as PropType>, + }, + setup(props) { + return () => + h('section', { 'aria-label': '人物 browse 列表', 'data-api-path': props.apipath }, [ + h('output', { 'aria-label': '人物 browse 查询' }, JSON.stringify(props.params)), + ]) + }, +}) + +const PageContentTitleStub = defineComponent({ + name: 'VPageContentTitle', + props: { title: String }, + setup(props) { + return () => h('h1', props.title) + }, +}) + +async function renderBrowse(paths: string[], query: Record) { + return renderWithProviders(BrowsePage, { + initialRoute: { path: '/browse', query }, + props: { paths }, + global: { + stubs: { + MediaCardListView: MediaCardListViewStub, + PersonCardListView: PersonCardListViewStub, + VPageContentTitle: PageContentTitleStub, + VScrollToTopBtn: true, + }, + }, + }) +} + +function projectedQuery(label: string) { + return JSON.parse(screen.getByRole('status', { name: label }).textContent || '{}') as Record +} + +describe('browse page', () => { + it('joins array paths and projects the complete route query to the media list', async () => { + const query = { + genre: ['动作', '科幻'], + page: '7', + sort: 'vote', + title: '相似媒体', + type: 'movie', + } + + await renderBrowse(['tmdb', 'recommend', '123', 'movie'], query) + + expect(screen.getByRole('heading', { name: '相似媒体' })).toBeInTheDocument() + expect(screen.getByRole('region', { name: '媒体 browse 列表' })).toHaveAttribute( + 'data-api-path', + 'tmdb/recommend/123/movie', + ) + expect(projectedQuery('媒体 browse 查询')).toEqual(query) + expect(screen.queryByRole('region', { name: '人物 browse 列表' })).not.toBeInTheDocument() + }) + + it('routes person queries to the person list and prefixes the title', async () => { + const query = { + page: '3', + source: 'themoviedb', + title: '张三', + type: 'person', + } + + await renderBrowse(['person', 'search'], query) + + expect(screen.getByRole('heading', { name: '演员: 张三' })).toBeInTheDocument() + expect(screen.getByRole('region', { name: '人物 browse 列表' })).toHaveAttribute( + 'data-api-path', + 'person/search', + ) + expect(projectedQuery('人物 browse 查询')).toEqual(query) + expect(screen.queryByRole('region', { name: '媒体 browse 列表' })).not.toBeInTheDocument() + }) +}) diff --git a/src/pages/__tests__/discover.spec.ts b/src/pages/__tests__/discover.spec.ts new file mode 100644 index 00000000..ef80d091 --- /dev/null +++ b/src/pages/__tests__/discover.spec.ts @@ -0,0 +1,425 @@ +import DiscoverPage from '@/pages/discover.vue' +import type { DiscoverSource } from '@/api/types' +import { fireEvent, waitFor } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { + discoverApiUrls, + discoverOrderConfigHandler, + discoverSourcesHandler, + saveDiscoverOrderHandler, + type DiscoverOrderItem, +} from '@tests/support/msw/handlers/discover' +import { server } from '@tests/support/msw/server' +import { HttpResponse, http } from 'msw' +import { defineComponent, h, ref, unref, type ComputedRef, type Ref } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +interface HeaderTabItem { + tab: string + title: string +} + +interface HeaderTabConfig { + appendButtons: Array<{ action: () => void }> + items: ComputedRef | Ref | HeaderTabItem[] + modelValue: Ref +} + +interface SharedDialogEvents { + close: () => void + save: (tabs: DiscoverSource[]) => Promise + 'update:modelValue': (value: boolean) => void +} + +interface SharedDialogController { + close: ReturnType + id: number + updateProps: ReturnType +} + +const mocks = vi.hoisted(() => ({ + controllers: [] as SharedDialogController[], + openSharedDialog: vi.fn(), + registerHeaderTab: vi.fn(), +})) + +vi.mock('@/composables/useDynamicHeaderTab', () => ({ + useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }), +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + +const BuiltInViewStub = defineComponent({ + name: 'BuiltInViewStub', + setup: () => () => h('section', '内置发现内容'), +}) + +const ExtraSourceViewStub = defineComponent({ + name: 'ExtraSourceView', + props: { + source: { type: Object, required: true }, + }, + setup(props) { + return () => h('section', { 'data-testid': 'extra-source' }, (props.source as DiscoverSource).name) + }, +}) + +function createSource(name: string, prefix: string, apiPath = `discover/${prefix}`): DiscoverSource { + return { + api_path: apiPath, + filter_params: { type: prefix }, + filter_ui: [], + mediaid_prefix: prefix, + name, + } +} + +function keepAliveHarness() { + return defineComponent({ + components: { DiscoverPage }, + setup() { + const active = ref(true) + return { active } + }, + template: ` + + + + `, + }) +} + +async function renderDiscover() { + const componentError = vi.fn() + const result = await renderWithProviders(keepAliveHarness(), { + initialRoute: '/discover', + global: { + config: { + errorHandler: componentError, + }, + stubs: { + BangumiView: BuiltInViewStub, + DoubanView: BuiltInViewStub, + ExtraSourceView: ExtraSourceViewStub, + TheMovieDbView: BuiltInViewStub, + VScrollToTopBtn: true, + }, + }, + }) + + return { ...result, componentError } +} + +function getHeaderConfig() { + const config = mocks.registerHeaderTab.mock.calls[0]?.[0] as HeaderTabConfig | undefined + if (!config) throw new Error('发现页未注册动态标签') + return config +} + +function getHeaderItems() { + return unref(getHeaderConfig().items) +} + +function getDialogCall(index = 0) { + const call = mocks.openSharedDialog.mock.calls[index] + if (!call) throw new Error(`未找到第 ${index + 1} 个排序弹窗`) + return { + events: call[2] as SharedDialogEvents, + tabs: (call[1] as { tabs: DiscoverSource[] }).tabs, + } +} + +async function reactivateDiscover() { + await fireEvent.click(document.querySelector('button') as HTMLButtonElement) + const buttons = Array.from(document.querySelectorAll('button')) + const activate = buttons.find(button => button.textContent === '启用发现页') + if (!activate) throw new Error('未找到启用发现页按钮') + await fireEvent.click(activate) +} + +describe('discover page', () => { + beforeEach(() => { + mocks.controllers.length = 0 + mocks.openSharedDialog.mockImplementation(() => { + const controller: SharedDialogController = { + close: vi.fn(), + id: mocks.controllers.length + 1, + updateProps: vi.fn(), + } + mocks.controllers.push(controller) + return controller + }) + }) + + it('uses local order, merges sources by prefix, and keeps unconfigured tabs stable', async () => { + const configRequested = vi.fn() + localStorage.setItem( + 'MP_DISCOVER_TAB_ORDER', + JSON.stringify([{ name: '豆瓣' }, { name: '自定义来源' }]), + ) + server.use( + discoverOrderConfigHandler([], 200, configRequested), + discoverSourcesHandler([ + createSource('自定义来源', 'custom'), + createSource('重复自定义来源', 'custom'), + createSource('伪豆瓣', 'douban'), + ]), + ) + + await renderDiscover() + + await waitFor(() => + expect(getHeaderItems().map(item => item.title)).toEqual(['豆瓣', '自定义来源', 'TheMovieDb', 'Bangumi']), + ) + expect(configRequested).not.toHaveBeenCalled() + expect(getHeaderItems().map(item => item.tab)).toEqual(['douban', 'custom', 'themoviedb', 'bangumi']) + }) + + it('loads remote order when local order is absent and backfills localStorage', async () => { + const configRequested = vi.fn() + const remoteOrder = [{ name: 'Bangumi' }, { name: 'TheMovieDb' }] + server.use( + discoverOrderConfigHandler(remoteOrder, 200, configRequested), + discoverSourcesHandler([createSource('自定义来源', 'custom')]), + ) + + await renderDiscover() + + await waitFor(() => expect(configRequested).toHaveBeenCalledOnce()) + await waitFor(() => + expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣', '自定义来源']), + ) + expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder)) + }) + + it('falls back to remote order when local JSON is malformed', async () => { + const configRequested = vi.fn() + const remoteOrder = [{ name: 'Bangumi' }, { name: 'TheMovieDb' }] + localStorage.setItem('MP_DISCOVER_TAB_ORDER', '{malformed') + server.use(discoverOrderConfigHandler(remoteOrder, 200, configRequested), discoverSourcesHandler([])) + + const { componentError } = await renderDiscover() + + await waitFor(() => expect(configRequested).toHaveBeenCalledOnce()) + await waitFor(() => expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣'])) + expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder)) + expect(componentError).not.toHaveBeenCalled() + }) + + it('keeps built-in and extra sources usable when the order config request fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + server.use( + discoverOrderConfigHandler(null, 500), + discoverSourcesHandler([createSource('可用扩展源', 'available')]), + ) + + await renderDiscover() + + await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('可用扩展源')) + expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', '可用扩展源']) + expect(getHeaderConfig().modelValue.value).toBe('themoviedb') + }) + + it('reuses the source request during initial KeepAlive activation', async () => { + const requested = vi.fn() + let releaseResponse = () => {} + const responseGate = new Promise(resolve => { + releaseResponse = resolve + }) + localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) + server.use( + discoverSourcesHandler([createSource('延迟来源', 'delayed')], 200, async () => { + requested() + await responseGate + }), + ) + + await renderDiscover() + await waitFor(() => expect(requested).toHaveBeenCalled()) + await new Promise(resolve => window.setTimeout(resolve, 25)) + + const requestCount = requested.mock.calls.length + releaseResponse() + await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('延迟来源')) + + expect(requestCount).toBe(1) + }) + + it('does not refetch sources when the initial response settles before activation', async () => { + const requested = vi.fn() + localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) + server.use(discoverSourcesHandler([createSource('快速来源', 'fast')], 200, requested)) + + await renderDiscover() + await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('快速来源')) + await new Promise(resolve => window.setTimeout(resolve, 25)) + + expect(requested).toHaveBeenCalledOnce() + }) + + it('removes a withdrawn source and falls back to the first sorted tab after reactivation', async () => { + let sources = [createSource('已撤销来源', 'withdrawn')] + const requested = vi.fn() + localStorage.setItem( + 'MP_DISCOVER_TAB_ORDER', + JSON.stringify([{ name: '已撤销来源' }, { name: 'TheMovieDb' }]), + ) + server.use( + http.get(discoverApiUrls.sources, () => { + requested() + return HttpResponse.json(sources) + }), + ) + await renderDiscover() + await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('已撤销来源')) + await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('withdrawn')) + const requestsBeforeReactivation = requested.mock.calls.length + sources = [] + + await reactivateDiscover() + + await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1)) + await waitFor(() => expect(getHeaderItems().map(item => item.title)).not.toContain('已撤销来源')) + await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('themoviedb')) + }) + + it('keeps the last successful source snapshot when reactivation fails', async () => { + let status = 200 + const requested = vi.fn() + localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) + vi.spyOn(console, 'log').mockImplementation(() => {}) + server.use( + http.get(discoverApiUrls.sources, () => { + requested() + return HttpResponse.json(status === 200 ? [createSource('缓存来源', 'cached')] : [], { status }) + }), + ) + await renderDiscover() + await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('缓存来源')) + const requestsBeforeReactivation = requested.mock.calls.length + status = 500 + + await reactivateDiscover() + + await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1)) + expect(getHeaderItems().map(item => item.title)).toContain('缓存来源') + expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', '缓存来源']) + }) + + it('replaces the header metadata when a source with the same prefix changes', async () => { + let sources = [createSource('旧来源名称', 'mutable', 'discover/old')] + const requested = vi.fn() + localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) + server.use( + http.get(discoverApiUrls.sources, () => { + requested() + return HttpResponse.json(sources) + }), + ) + await renderDiscover() + await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('旧来源名称')) + const requestsBeforeReactivation = requested.mock.calls.length + sources = [createSource('新来源名称', 'mutable', 'discover/new')] + + await reactivateDiscover() + + await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1)) + await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('新来源名称')) + expect(getHeaderItems().map(item => item.title)).not.toContain('旧来源名称') + }) + + it('saves the exact visible order through the shared dialog boundary', async () => { + const savedOrders: DiscoverOrderItem[][] = [] + localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) + server.use( + discoverSourcesHandler([createSource('自定义来源', 'custom')]), + saveDiscoverOrderHandler(order => { + savedOrders.push(order) + }), + ) + await renderDiscover() + await waitFor(() => expect(getHeaderItems()).toHaveLength(4)) + + getHeaderConfig().appendButtons[0].action() + const { events, tabs } = getDialogCall() + const reorderedTabs = [tabs[3], tabs[1], tabs[0], tabs[2]] + await events.save(reorderedTabs) + + const expectedOrder = reorderedTabs.map(item => ({ name: item.name })) + expect(savedOrders).toEqual([expectedOrder]) + expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(expectedOrder)) + expect(getHeaderItems().map(item => item.title)).toEqual(reorderedTabs.map(item => item.name)) + expect(mocks.controllers[0].close).toHaveBeenCalledOnce() + }) + + it('closes the previous controller before opening another order dialog', async () => { + localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) + server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')])) + await renderDiscover() + await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源')) + + const action = getHeaderConfig().appendButtons[0].action + action() + action() + + expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2) + expect(mocks.controllers[0].close).toHaveBeenCalledOnce() + expect(mocks.controllers[1].close).not.toHaveBeenCalled() + }) + + it('releases only the controller named by close and model update events', async () => { + localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) + server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')])) + await renderDiscover() + await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源')) + const action = getHeaderConfig().appendButtons[0].action + + action() + getDialogCall(0).events.close() + action() + expect(mocks.controllers[0].close).not.toHaveBeenCalled() + + getDialogCall(1).events['update:modelValue'](true) + action() + expect(mocks.controllers[1].close).toHaveBeenCalledOnce() + + getDialogCall(2).events['update:modelValue'](false) + action() + expect(mocks.controllers[2].close).not.toHaveBeenCalled() + }) + + it('does not let a pending save from an old dialog close a newer controller', async () => { + let releaseSave = () => {} + const saveGate = new Promise(resolve => { + releaseSave = resolve + }) + const saveStarted = vi.fn() + localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([])) + server.use( + discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]), + saveDiscoverOrderHandler(async () => { + saveStarted() + await saveGate + }), + ) + await renderDiscover() + await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源')) + const action = getHeaderConfig().appendButtons[0].action + + action() + const firstDialog = getDialogCall(0) + const pendingSave = firstDialog.events.save(firstDialog.tabs) + await waitFor(() => expect(saveStarted).toHaveBeenCalledOnce()) + firstDialog.events.close() + action() + expect(mocks.controllers).toHaveLength(2) + + releaseSave() + await pendingSave + + expect(mocks.controllers[1].close).not.toHaveBeenCalled() + }) +}) diff --git a/src/pages/browse.vue b/src/pages/browse.vue index d922d508..850532f1 100644 --- a/src/pages/browse.vue +++ b/src/pages/browse.vue @@ -3,37 +3,33 @@ import MediaCardListView from '@/views/discover/MediaCardListView.vue' import PersonCardListView from '@/views/discover/PersonCardListView.vue' import { useI18n } from 'vue-i18n' -// 国际化 const { t } = useI18n() -// 输入参数 const props = defineProps({ - // API路径 - paths: Array as PropType | PropType, + // `/browse/:paths+` 捕获的 API 路径段。 + paths: { + type: Array as PropType, + required: true, + }, }) -// 路由参数 const route = useRoute() -// 标题 let title = route.query?.title?.toString() -// 类型 const type = route.query?.type?.toString() if (type === 'person') title = t('browse.actor') + ': ' + title -// 计算API路径 -function getApiPath(paths: string[] | string) { - if (Array.isArray(paths)) return paths.join('/') - else return paths +function getApiPath(paths: string[]) { + return paths.join('/') } diff --git a/src/views/discover/MediaCardListView.vue b/src/views/discover/MediaCardListView.vue index 88176779..f161b543 100644 --- a/src/views/discover/MediaCardListView.vue +++ b/src/views/discover/MediaCardListView.vue @@ -8,10 +8,12 @@ import { useI18n } from 'vue-i18n' const { t } = useI18n() -// 输入参数 const props = defineProps({ - apipath: String, - params: Object as PropType<{ [key: string]: any }>, + apipath: { + type: String, + required: true, + }, + params: Object as PropType>, }) // 判断是否有滚动条 @@ -25,6 +27,9 @@ const page = ref(1) // 是否加载中 const loading = ref(false) +// 首次成功响应前,请求失败只展示错误和重试入口。 +const loadFailed = ref(false) + // 是否加载完成 const isRefreshed = ref(false) @@ -34,14 +39,15 @@ const dataList = shallowRef([]) // 用于保存已处理过的 key const seenKeys = new Set() +// 保存本次列表生命周期内已经处理过的原始页签名。 +const seenPageSignatures = new Set() + // 拼装参数 function getParams() { - let params = { + return { + ...props.params, page: page.value, } - if (props.params) params = { ...params, ...props.params } - - return params } // MediaInfo 去重的字段 @@ -58,9 +64,14 @@ const dedupFields = [ 'media_id', ] as const +// 去重、分页终止和渲染必须共用同一媒体身份,避免状态与 DOM key 分叉。 +function getMediaIdentity(item: MediaInfo) { + return JSON.stringify(dedupFields.map(field => item[field] ?? null)) +} + function deduplicate(items: MediaInfo[]): MediaInfo[] { return items.filter(item => { - const key = dedupFields.map(field => String(item[field])).join('~') + const key = getMediaIdentity(item) if (seenKeys.has(key)) { return false } @@ -74,92 +85,87 @@ function appendData(items: MediaInfo[]) { } async function loadPageData() { - const rawData: MediaInfo[] = await api.get(props.apipath!, { + const rawData: MediaInfo[] = await api.get(props.apipath, { params: getParams(), }) + const pageSignature = [...new Set(rawData.map(getMediaIdentity))].sort().join('\n') + const isTerminal = rawData.length === 0 || seenPageSignatures.has(pageSignature) + + if (!isTerminal) seenPageSignatures.add(pageSignature) return { - rawCount: rawData.length, - uniqueData: deduplicate(rawData), + isTerminal, + uniqueData: isTerminal ? [] : deduplicate(rawData), } } // 获取列表数据 -async function fetchData({ done }: { done: any }) { +async function fetchData({ + done, +}: { + done: (status: 'empty' | 'error' | 'loading' | 'ok') => void +}) { + if (loading.value) { + done('ok') + return + } + + loading.value = true + loadFailed.value = false try { - if (!props.apipath) return - - // 如果正在加载中,直接返回 - if (loading.value) { - done('ok') - return - } - - // 加载到满屏或者加载出错 + // 未形成滚动区域时连续填页;已有滚动区域时每次只消费一页。 if (!hasScroll()) { - // 加载多次 while (!hasScroll()) { - // 设置加载中 - loading.value = true - // 请求API - const { rawCount, uniqueData } = await loadPageData() - // 取消加载中 - loading.value = false - // 标计为已请求完成 + const { isTerminal, uniqueData } = await loadPageData() isRefreshed.value = true - if (rawCount === 0) { - // 如果没有数据,跳出 + if (isTerminal) { done('empty') return } - // 合并数据 appendData(uniqueData) - // 页码+1 page.value++ - // 返回加载成功 - done('ok') await nextTick() } } else { - // 加载一次 - // 设置加载中 - loading.value = true - // 请求API - const { rawCount, uniqueData } = await loadPageData() - // 标计为已请求完成 + const { isTerminal, uniqueData } = await loadPageData() isRefreshed.value = true - if (rawCount === 0) { - // 如果没有数据,跳出 + if (isTerminal) { done('empty') + return } else { - // 合并数据 appendData(uniqueData) - // 页码+1 page.value++ - // 返回加载成功 - done('ok') } } - // 取消加载中 - loading.value = false + done('ok') } catch (error) { console.error(error) - // 返回加载失败 + loadFailed.value = true done('error') + } finally { + loading.value = false } }