diff --git a/src/api/types.ts b/src/api/types.ts index e4782402..57cc4990 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -268,6 +268,64 @@ export interface TransferHistory { src_fileitem?: FileItem } +// 下载历史记录 +export interface DownloadHistory { + // ID + id: number + // 保存路径 + path?: string + // 类型:电影、电视剧 + type?: string + // 标题 + title?: string + // 年份 + year?: string + // TMDB ID + tmdbid?: number + // IMDB ID + imdbid?: string + // TVDB ID + tvdbid?: number + // 豆瓣 ID + doubanid?: string + // Bangumi ID + bangumiid?: number + // AniList ID + anilistid?: number + // 媒体数据源 + media_source?: MediaDataSource + // 数据源原生 ID + media_id?: string + // 季 Sxx + seasons?: string + // 集 Exx + episodes?: string + // 海报或背景图 + image?: string + // 下载器 Hash + download_hash?: string + // 种子名称 + torrent_name?: string + // 种子描述 + torrent_description?: string + // 站点 + torrent_site?: string + // 下载用户 ID + userid?: string + // 下载用户名或插件名 + username?: string + // 下载渠道 + channel?: string + // 创建时间 + date?: string + // 附加信息 + note?: unknown + // 自定义媒体类别 + media_category?: string + // 自定义剧集组 + episode_group?: string +} + // 媒体信息 export interface MediaInfo { // 来源:themoviedb、douban、bangumi、anilist diff --git a/src/components/dialog/DownloadHistoryDialog.vue b/src/components/dialog/DownloadHistoryDialog.vue new file mode 100644 index 00000000..7c75bb29 --- /dev/null +++ b/src/components/dialog/DownloadHistoryDialog.vue @@ -0,0 +1,271 @@ + + + + + + + {{ t('dialog.downloadHistory.title') }} + + + + + + + + + + + {{ t('dialog.downloadHistory.loadFailed') }} + + {{ t('common.retry') }} + + + + + + + + + + + + + + + + + + + + {{ getHistoryTitle(item) }} + ({{ item.year }}) + + + + {{ getSeasonEpisode(item) }} + + + {{ item.torrent_site }} + + + + {{ item.torrent_name }} + + + {{ formatDateDifference(item.date) }} + + + + + + + + + + + + {{ t('common.delete') }} + + + + + + + + + + + + + + + + {{ t('dialog.downloadHistory.noData') }} + + + {{ t('dialog.downloadHistory.noDataHint') }} + + + + + + + diff --git a/src/components/dialog/__tests__/DownloadHistoryDialog.spec.ts b/src/components/dialog/__tests__/DownloadHistoryDialog.spec.ts new file mode 100644 index 00000000..311fb6a4 --- /dev/null +++ b/src/components/dialog/__tests__/DownloadHistoryDialog.spec.ts @@ -0,0 +1,238 @@ +import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue' +import type { DownloadHistory } from '@/api/types' +import DownloadHistoryDialog from '@/components/dialog/DownloadHistoryDialog.vue' +import { screen, waitFor, within } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { + deleteDownloadHistoryHandler, + downloadApiUrls, + downloadHistoryHandler, +} from '@tests/support/msw/handlers/download' +import { server } from '@tests/support/msw/server' +import { renderWithProviders } from '@tests/support/render' +import { HttpResponse, http } from 'msw' +import { defineComponent, h, onMounted, ref, type PropType } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type InfiniteScrollStatus = 'empty' | 'error' | 'ok' + +const InfiniteScrollStub = defineComponent({ + name: 'VInfiniteScroll', + emits: ['load'], + setup(_props, { emit, slots }) { + const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle') + + function load() { + status.value = 'loading' + emit('load', { + done(nextStatus: InfiniteScrollStatus) { + status.value = nextStatus === 'ok' ? 'idle' : nextStatus + }, + }) + } + + onMounted(load) + + return () => + h('div', { 'data-testid': 'history-infinite-scroll' }, [ + status.value === 'loading' ? slots.loading?.({}) : null, + status.value === 'error' + ? slots.error?.({ + props: { onClick: load }, + }) + : null, + status.value === 'empty' ? slots.empty?.({}) : null, + slots.default?.(), + h( + 'button', + { + 'aria-label': '加载更多下载历史', + type: 'button', + onClick: load, + }, + '加载更多下载历史', + ), + ]) + }, +}) + +const VirtualScrollStub = defineComponent({ + name: 'VVirtualScroll', + props: { + items: { + type: Array as PropType, + default: () => [], + }, + }, + setup(props, { slots }) { + const itemRef = () => {} + return () => + h( + 'div', + props.items.map(item => slots.default?.({ item, itemRef })), + ) + }, +}) + +const MenuStub = defineComponent({ + name: 'VMenu', + setup(_props, { slots }) { + return () => h('div', slots.default?.()) + }, +}) + +let historySeed = 5000 + +function createHistory(overrides: Partial = {}): DownloadHistory { + historySeed += 1 + return { + date: '2026-08-01 12:00:00', + download_hash: `hash-${historySeed}`, + episodes: 'E01-E02', + id: historySeed, + image: `https://images.example.com/history-${historySeed}.jpg`, + path: `/downloads/history-${historySeed}`, + seasons: 'S01', + title: `历史媒体 ${historySeed}`, + torrent_name: `Torrent.Release.${historySeed}`, + torrent_site: '示例站', + type: '电视剧', + username: 'tester', + year: '2026', + ...overrides, + } +} + +function historyRow(item: DownloadHistory) { + const title = screen.getByText(item.title!) + const row = title.closest('.v-list-item') + if (!row) throw new Error(`History row ${item.id} was not rendered`) + return row as HTMLElement +} + +async function renderDialog() { + const close = vi.fn() + const result = await renderWithProviders(DownloadHistoryDialog, { + props: { + modelValue: true, + onClose: close, + }, + global: { + components: { + VDialogCloseBtn: DialogCloseBtn, + }, + stubs: { + VInfiniteScroll: InfiniteScrollStub, + VMenu: MenuStub, + VVirtualScroll: VirtualScrollStub, + }, + }, + }) + return { ...result, close } +} + +describe('DownloadHistoryDialog', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('loads and renders download history with page parameters', async () => { + const item = createHistory({ title: '首载剧集' }) + const requests: URL[] = [] + server.use( + downloadHistoryHandler([item], 200, url => { + requests.push(url) + }), + ) + + await renderDialog() + + expect(await screen.findByText('首载剧集')).toBeInTheDocument() + expect(screen.getByText('(2026)')).toBeInTheDocument() + expect(screen.getByText('S01E01-E02').closest('.v-chip')).toBeInTheDocument() + expect(screen.getByText('示例站').closest('.v-chip')).toBeInTheDocument() + expect(screen.getByText(item.torrent_name!)).toBeInTheDocument() + expect(screen.getByTestId('history-infinite-scroll')).toHaveClass('download-history-dialog__scroll') + expect(document.querySelector('.download-history-dialog__content')).toBeInTheDocument() + expect(requests).toHaveLength(1) + expect(requests[0].searchParams.get('page')).toBe('1') + expect(requests[0].searchParams.get('count')).toBe('30') + }) + + it('appends later pages and preserves existing rows at the end', async () => { + const first = createHistory({ title: '第一页历史' }) + const second = createHistory({ title: '第二页历史' }) + const requestedPages: string[] = [] + server.use( + http.get(downloadApiUrls.history, ({ request }) => { + const page = new URL(request.url).searchParams.get('page') ?? '' + requestedPages.push(page) + if (page === '1') return HttpResponse.json([first]) + if (page === '2') return HttpResponse.json([second]) + return HttpResponse.json([]) + }), + ) + const user = userEvent.setup() + + await renderDialog() + + expect(await screen.findByText('第一页历史')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '加载更多下载历史' })) + expect(await screen.findByText('第二页历史')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '加载更多下载历史' })) + await waitFor(() => expect(requestedPages).toEqual(['1', '2', '3'])) + + expect(screen.getByText('第一页历史')).toBeInTheDocument() + expect(screen.getByText('第二页历史')).toBeInTheDocument() + }) + + it('renders an empty state after the first empty page', async () => { + server.use(downloadHistoryHandler()) + + await renderDialog() + + expect(await screen.findByText('没有下载历史')).toBeInTheDocument() + expect(screen.getByText('已添加的下载任务会显示在这里')).toBeInTheDocument() + }) + + it('deletes one history row with its complete payload', async () => { + const item = createHistory({ title: '待删除历史' }) + const deletedBodies: DownloadHistory[] = [] + server.use( + downloadHistoryHandler([item]), + deleteDownloadHistoryHandler({ success: true }, 200, body => { + deletedBodies.push(body) + }), + ) + const user = userEvent.setup() + + await renderDialog() + + expect(await screen.findByText('待删除历史')).toBeInTheDocument() + await user.click(within(historyRow(item)).getByText('删除')) + await waitFor(() => expect(screen.queryByText('待删除历史')).not.toBeInTheDocument()) + + expect(deletedBodies).toEqual([item]) + }) + + it('offers a retry after the first load fails', async () => { + const item = createHistory({ title: '重试成功历史' }) + let requestCount = 0 + server.use( + http.get(downloadApiUrls.history, () => { + requestCount += 1 + if (requestCount === 1) return HttpResponse.json({}, { status: 500 }) + return HttpResponse.json([item]) + }), + ) + const user = userEvent.setup() + + await renderDialog() + + expect(await screen.findByRole('alert')).toHaveTextContent('下载历史加载失败') + await user.click(screen.getByRole('button', { name: /重试/ })) + + expect(await screen.findByText('重试成功历史')).toBeInTheDocument() + expect(requestCount).toBe(2) + }) +}) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index a3c3a385..cb67cc6f 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -3319,6 +3319,15 @@ export default { noData: 'No completed subscriptions', noDataHint: 'Completed subscription history will be displayed here', }, + downloadHistory: { + title: 'Download History', + actions: 'Download history actions', + unknownTitle: 'Unknown Media', + noData: 'No download history', + noDataHint: 'Added download tasks will be displayed here', + loadFailed: 'Failed to load download history', + deleteFailed: 'Failed to delete download history', + }, siteUserData: { title: 'Site User Data', updateTime: 'Update Time', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index b7b6e452..698a2d6d 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -3262,6 +3262,15 @@ export default { noData: '没有已完成的订阅', noDataHint: '完成的订阅会显示在这里', }, + downloadHistory: { + title: '下载历史', + actions: '下载历史操作', + unknownTitle: '未知媒体', + noData: '没有下载历史', + noDataHint: '已添加的下载任务会显示在这里', + loadFailed: '下载历史加载失败', + deleteFailed: '下载历史删除失败', + }, siteUserData: { title: '站点用户数据', updateTime: '更新时间', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index b50f6771..70185da3 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -3261,6 +3261,15 @@ export default { noData: '沒有已完成的訂閱', noDataHint: '完成的訂閱會顯示在這裡', }, + downloadHistory: { + title: '下載歷史', + actions: '下載歷史操作', + unknownTitle: '未知媒體', + noData: '沒有下載歷史', + noDataHint: '已添加的下載任務會顯示在這裡', + loadFailed: '下載歷史載入失敗', + deleteFailed: '下載歷史刪除失敗', + }, siteUserData: { title: '站點用戶數據', updateTime: '更新時間', diff --git a/src/pages/__tests__/downloading.spec.ts b/src/pages/__tests__/downloading.spec.ts new file mode 100644 index 00000000..8317d130 --- /dev/null +++ b/src/pages/__tests__/downloading.spec.ts @@ -0,0 +1,110 @@ +import DownloadingPage from '@/pages/downloading.vue' +import { fireEvent, waitFor } from '@testing-library/vue' +import { renderWithProviders } from '@tests/support/render' +import { computed, defineComponent, h, unref, type ComputedRef } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + appMode: false, + apiGet: vi.fn(), + openSharedDialog: vi.fn(), + registerHeaderTab: vi.fn(), + useDynamicButton: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: { + get: (...args: unknown[]) => mocks.apiGet(...args), + }, +})) + +vi.mock('@/composables/useDynamicHeaderTab', () => ({ + useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }), +})) + +vi.mock('@/composables/useDynamicButton', () => ({ + useDynamicButton: (options: unknown) => mocks.useDynamicButton(options), +})) + +vi.mock('@/composables/usePWA', () => ({ + usePWA: () => ({ appMode: computed(() => mocks.appMode) }), +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + +vi.mock('@/composables/useKeepAliveRefresh', () => ({ + useKeepAliveRefresh: vi.fn(), +})) + +const DownloadingListViewStub = defineComponent({ + name: 'DownloadingListView', + props: { + active: Boolean, + name: String, + }, + setup(props) { + return () => h('div', `${props.name}:${props.active}`) + }, +}) + +async function renderPage(appMode: boolean) { + mocks.appMode = appMode + mocks.apiGet.mockResolvedValue([{ name: 'qb-main' }]) + return renderWithProviders(DownloadingPage, { + initialRoute: '/downloading', + global: { + stubs: { + DownloadingListView: DownloadingListViewStub, + NoDataFound: true, + }, + }, + }) +} + +function getDynamicButtonConfig() { + const config = mocks.useDynamicButton.mock.calls.at(-1)?.[0] + if (!config) throw new Error('Dynamic button was not registered') + return config as { + icon: string + color?: string + onClick: () => void + permission: string + show: ComputedRef + } +} + +describe('Downloading page history action', () => { + beforeEach(() => { + mocks.appMode = false + mocks.apiGet.mockReset() + mocks.openSharedDialog.mockReset() + mocks.registerHeaderTab.mockReset() + mocks.useDynamicButton.mockReset() + }) + + it('renders a compact desktop FAB that opens download history', async () => { + await renderPage(false) + + await waitFor(() => expect(document.querySelector('.compact-fab button')).toBeInTheDocument()) + expect(document.querySelector('.compact-fab--primary')).toBeInTheDocument() + await fireEvent.click(document.querySelector('.compact-fab button') as HTMLButtonElement) + + expect(mocks.openSharedDialog).toHaveBeenCalledWith(expect.any(Object), {}, {}, { closeOn: ['close'] }) + }) + + it('uses the mobile dynamic button instead of the desktop FAB', async () => { + await renderPage(true) + const dynamicButton = getDynamicButtonConfig() + + expect(document.querySelector('.compact-fab')).not.toBeInTheDocument() + expect(dynamicButton.icon).toBe('mdi-history') + expect(dynamicButton.color).toBeUndefined() + expect(dynamicButton.permission).toBe('manage') + expect(unref(dynamicButton.show)).toBe(true) + + dynamicButton.onClick() + expect(mocks.openSharedDialog).toHaveBeenCalledWith(expect.any(Object), {}, {}, { closeOn: ['close'] }) + }) +}) diff --git a/src/pages/downloading.vue b/src/pages/downloading.vue index 5df2b27d..c0c77fb4 100644 --- a/src/pages/downloading.vue +++ b/src/pages/downloading.vue @@ -6,13 +6,30 @@ import NoDataFound from '@/components/states/NoDataFound.vue' import { useI18n } from 'vue-i18n' import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab' import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh' +import { useDynamicButton } from '@/composables/useDynamicButton' +import { usePWA } from '@/composables/usePWA' +import { openSharedDialog } from '@/composables/useSharedDialog' + +const DownloadHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/DownloadHistoryDialog.vue')) // 国际化 const { t } = useI18n() const route = useRoute() +const { appMode } = usePWA() const activeTab = ref((route.query.tab as string) || '') +function openDownloadHistoryDialog() { + openSharedDialog(DownloadHistoryDialog, {}, {}, { closeOn: ['close'] }) +} + +useDynamicButton({ + icon: 'mdi-history', + onClick: openDownloadHistoryDialog, + permission: 'manage', + show: computed(() => appMode.value), +}) + // 下载器 const downloaders = ref([]) @@ -67,4 +84,16 @@ useKeepAliveRefresh(async () => { :error-title="t('downloading.noDownloader')" :error-description="t('downloading.configureDownloader')" /> + + + + + + diff --git a/tests/support/msw/handlers/download.ts b/tests/support/msw/handlers/download.ts index ac0d0286..332b8f5c 100644 --- a/tests/support/msw/handlers/download.ts +++ b/tests/support/msw/handlers/download.ts @@ -1,4 +1,4 @@ -import type { DownloadingInfo } from '@/api/types' +import type { DownloadHistory, DownloadingInfo } from '@/api/types' import { HttpResponse, http, type JsonBodyType } from 'msw' const API_BASE_URL = 'http://localhost/api/v1/' @@ -12,6 +12,7 @@ export const downloadApiUrls = { action: (operation: 'start' | 'stop', hash: string) => new URL(`download/${operation}/${hash}`, API_BASE_URL).href, delete: (hash: string) => new URL(`download/${hash}`, API_BASE_URL).href, list: new URL('download/', API_BASE_URL).href, + history: new URL('history/download', API_BASE_URL).href, } function jsonResponse(body: JsonBodyType, status: number) { @@ -60,3 +61,29 @@ export function deleteDownloadHandler( return jsonResponse(response, status) }) } + +/** 拦截下载历史分页查询,并保留分页参数供断言。 */ +export function downloadHistoryHandler( + response: DownloadHistory[] | ((url: URL) => DownloadHistory[] | Promise) = [], + status = 200, + onRequest: (url: URL) => void | Promise = () => {}, +) { + return http.get(downloadApiUrls.history, async ({ request }) => { + const url = new URL(request.url) + await onRequest(url) + const body = typeof response === 'function' ? await response(url) : response + return jsonResponse(body as unknown as JsonBodyType, status) + }) +} + +/** 拦截下载历史删除请求,并保留请求体供断言。 */ +export function deleteDownloadHistoryHandler( + response: DownloadMutationResponse = { success: true }, + status = 200, + onRequest: (body: DownloadHistory) => void | Promise = () => {}, +) { + return http.delete(downloadApiUrls.history, async ({ request }) => { + await onRequest((await request.json()) as DownloadHistory) + return jsonResponse(response, status) + }) +}