feat(download): add download history dialog

This commit is contained in:
jxxghp
2026-08-03 08:45:35 +08:00
parent a7df9c432e
commit aa63782645
9 changed files with 761 additions and 1 deletions
+58
View File
@@ -268,6 +268,64 @@ export interface TransferHistory {
src_fileitem?: FileItem 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 { export interface MediaInfo {
// 来源:themoviedb、douban、bangumi、anilist // 来源:themoviedb、douban、bangumi、anilist
@@ -0,0 +1,271 @@
<script lang="ts" setup>
import api from '@/api'
import type { DownloadHistory } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { formatDateDifference } from '@core/utils/formatters'
import noImage from '@images/no-image.jpeg'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
const emit = defineEmits(['close'])
const { t } = useI18n()
const display = useDisplay()
const globalSettingsStore = useGlobalSettingsStore()
const $toast = useToast()
const historyList = ref<DownloadHistory[]>([])
const currentPage = ref(1)
const pageSize = 30
const loading = ref(false)
const isRefreshed = ref(false)
async function loadHistory({ done }: { done: (status: 'empty' | 'error' | 'ok') => void }) {
if (loading.value) {
done('ok')
return
}
try {
loading.value = true
const currentData: DownloadHistory[] = await api.get('history/download', {
params: {
page: currentPage.value,
count: pageSize,
},
})
isRefreshed.value = true
if (currentData.length === 0) {
done('empty')
return
}
historyList.value = [...historyList.value, ...currentData]
currentPage.value++
done('ok')
} catch (error) {
console.error(error)
done('error')
} finally {
loading.value = false
}
}
async function deleteHistory(item: DownloadHistory) {
try {
const result: { success?: boolean } = await api.delete('history/download', { data: item })
if (result.success) {
historyList.value = historyList.value.filter(history => history.id !== item.id)
return
}
$toast.error(t('dialog.downloadHistory.deleteFailed'))
} catch (error) {
console.error(error)
$toast.error(t('dialog.downloadHistory.deleteFailed'))
}
}
function getHistoryImage(item: DownloadHistory) {
if (!item.image) return noImage
return getDisplayImageUrl(item.image, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
}
function getHistoryTitle(item: DownloadHistory) {
return item.title || item.torrent_name || t('dialog.downloadHistory.unknownTitle')
}
function getSeasonEpisode(item: DownloadHistory) {
return `${item.seasons || ''}${item.episodes || ''}`
}
</script>
<template>
<VDialog
scrollable
max-width="50rem"
:height="display.mdAndUp.value ? '42rem' : undefined"
:fullscreen="!display.mdAndUp.value"
>
<VCard class="download-history-dialog mx-auto d-flex flex-column" width="100%">
<VCardItem class="flex-none">
<VCardTitle>{{ t('dialog.downloadHistory.title') }}</VCardTitle>
</VCardItem>
<VDivider class="flex-none" />
<VDialogCloseBtn @click="emit('close')" />
<VInfiniteScroll
v-if="!isRefreshed || historyList.length > 0"
mode="intersect"
side="end"
:items="historyList"
class="download-history-dialog__scroll"
@load="loadHistory"
>
<template #loading>
<LoadingBanner />
</template>
<template #error="{ props: retryProps }">
<div class="d-flex flex-column align-center ga-2 py-4" role="alert">
<span class="text-medium-emphasis">{{ t('dialog.downloadHistory.loadFailed') }}</span>
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
{{ t('common.retry') }}
</VBtn>
</div>
</template>
<template #empty />
<VList lines="three" class="download-history-dialog__content py-0">
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="112">
<template #default="{ item, itemRef }">
<div :ref="itemRef">
<VListItem class="download-history-item">
<template #prepend>
<VImg
height="64"
width="96"
:src="getHistoryImage(item)"
aspect-ratio="3/2"
class="download-history-item__image me-3 rounded-md"
cover
>
<template #placeholder>
<VSkeletonLoader class="h-100 w-100" />
</template>
</VImg>
</template>
<VListItemTitle class="download-history-item__title">
{{ getHistoryTitle(item) }}
<span v-if="item.year" class="text-body-2 text-medium-emphasis">({{ item.year }})</span>
</VListItemTitle>
<div v-if="getSeasonEpisode(item) || item.torrent_site" class="download-history-item__chips mt-1">
<VChip v-if="getSeasonEpisode(item)" color="primary" size="x-small" variant="tonal">
{{ getSeasonEpisode(item) }}
</VChip>
<VChip v-if="item.torrent_site" color="secondary" size="x-small" variant="tonal">
{{ item.torrent_site }}
</VChip>
</div>
<VListItemSubtitle v-if="item.torrent_name" class="download-history-item__torrent mt-1">
{{ item.torrent_name }}
</VListItemSubtitle>
<VListItemSubtitle v-if="item.date" class="mt-1">
{{ formatDateDifference(item.date) }}
</VListItemSubtitle>
<template #append>
<IconBtn :aria-label="t('dialog.downloadHistory.actions')">
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem base-color="error" @click="deleteHistory(item)">
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
<VListItemTitle>{{ t('common.delete') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
</VListItem>
</div>
</template>
</VVirtualScroll>
</VList>
</VInfiniteScroll>
<VCardText v-else class="download-history-empty flex-grow-1">
<VIcon class="download-history-empty__icon" icon="mdi-download-off-outline" size="30" />
<div class="download-history-empty__headline">
{{ t('dialog.downloadHistory.noData') }}
</div>
<div class="download-history-empty__description">
{{ t('dialog.downloadHistory.noDataHint') }}
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.download-history-dialog {
block-size: 100%;
overflow: hidden !important;
}
.download-history-dialog__scroll {
flex: 1 1 0;
min-block-size: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.download-history-dialog__content {
overflow: visible !important;
}
.download-history-item {
min-block-size: 7rem;
}
.download-history-item__image {
flex: none;
}
.download-history-item__title {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
.download-history-item__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.download-history-item__torrent {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-history-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
min-block-size: 13rem;
padding-block: 2.5rem !important;
padding-inline: 1.5rem !important;
text-align: center;
}
.download-history-empty__icon {
color: rgb(var(--v-theme-primary));
}
.download-history-empty__headline {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1rem;
font-weight: 600;
}
.download-history-empty__description {
max-inline-size: 22rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem;
}
@media (width <= 600px) {
.download-history-item__image {
block-size: 54px !important;
inline-size: 81px !important;
}
}
</style>
@@ -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<DownloadHistory[]>,
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> = {}): 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)
})
})
+9
View File
@@ -3319,6 +3319,15 @@ export default {
noData: 'No completed subscriptions', noData: 'No completed subscriptions',
noDataHint: 'Completed subscription history will be displayed here', 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: { siteUserData: {
title: 'Site User Data', title: 'Site User Data',
updateTime: 'Update Time', updateTime: 'Update Time',
+9
View File
@@ -3262,6 +3262,15 @@ export default {
noData: '没有已完成的订阅', noData: '没有已完成的订阅',
noDataHint: '完成的订阅会显示在这里', noDataHint: '完成的订阅会显示在这里',
}, },
downloadHistory: {
title: '下载历史',
actions: '下载历史操作',
unknownTitle: '未知媒体',
noData: '没有下载历史',
noDataHint: '已添加的下载任务会显示在这里',
loadFailed: '下载历史加载失败',
deleteFailed: '下载历史删除失败',
},
siteUserData: { siteUserData: {
title: '站点用户数据', title: '站点用户数据',
updateTime: '更新时间', updateTime: '更新时间',
+9
View File
@@ -3261,6 +3261,15 @@ export default {
noData: '沒有已完成的訂閱', noData: '沒有已完成的訂閱',
noDataHint: '完成的訂閱會顯示在這裡', noDataHint: '完成的訂閱會顯示在這裡',
}, },
downloadHistory: {
title: '下載歷史',
actions: '下載歷史操作',
unknownTitle: '未知媒體',
noData: '沒有下載歷史',
noDataHint: '已添加的下載任務會顯示在這裡',
loadFailed: '下載歷史載入失敗',
deleteFailed: '下載歷史刪除失敗',
},
siteUserData: { siteUserData: {
title: '站點用戶數據', title: '站點用戶數據',
updateTime: '更新時間', updateTime: '更新時間',
+110
View File
@@ -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<boolean>
}
}
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'] })
})
})
+29
View File
@@ -6,13 +6,30 @@ import NoDataFound from '@/components/states/NoDataFound.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab' import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh' 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 { t } = useI18n()
const route = useRoute() const route = useRoute()
const { appMode } = usePWA()
const activeTab = ref<string>((route.query.tab as string) || '') const activeTab = ref<string>((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<DownloaderConf[]>([]) const downloaders = ref<DownloaderConf[]>([])
@@ -67,4 +84,16 @@ useKeepAliveRefresh(async () => {
:error-title="t('downloading.noDownloader')" :error-title="t('downloading.noDownloader')"
:error-description="t('downloading.configureDownloader')" :error-description="t('downloading.configureDownloader')"
/> />
<Teleport to="body" v-if="!appMode && route.path === '/downloading'">
<div class="compact-fab-stack">
<VFab
icon="mdi-history"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openDownloadHistoryDialog"
/>
</div>
</Teleport>
</template> </template>
+28 -1
View File
@@ -1,4 +1,4 @@
import type { DownloadingInfo } from '@/api/types' import type { DownloadHistory, DownloadingInfo } from '@/api/types'
import { HttpResponse, http, type JsonBodyType } from 'msw' import { HttpResponse, http, type JsonBodyType } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/' 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, 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, delete: (hash: string) => new URL(`download/${hash}`, API_BASE_URL).href,
list: new URL('download/', 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) { function jsonResponse(body: JsonBodyType, status: number) {
@@ -60,3 +61,29 @@ export function deleteDownloadHandler(
return jsonResponse(response, status) return jsonResponse(response, status)
}) })
} }
/** 拦截下载历史分页查询,并保留分页参数供断言。 */
export function downloadHistoryHandler(
response: DownloadHistory[] | ((url: URL) => DownloadHistory[] | Promise<DownloadHistory[]>) = [],
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
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<void> = () => {},
) {
return http.delete(downloadApiUrls.history, async ({ request }) => {
await onRequest((await request.json()) as DownloadHistory)
return jsonResponse(response, status)
})
}