mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 07:28:37 +08:00
feat(download): add download history dialog
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -3262,6 +3262,15 @@ export default {
|
|||||||
noData: '没有已完成的订阅',
|
noData: '没有已完成的订阅',
|
||||||
noDataHint: '完成的订阅会显示在这里',
|
noDataHint: '完成的订阅会显示在这里',
|
||||||
},
|
},
|
||||||
|
downloadHistory: {
|
||||||
|
title: '下载历史',
|
||||||
|
actions: '下载历史操作',
|
||||||
|
unknownTitle: '未知媒体',
|
||||||
|
noData: '没有下载历史',
|
||||||
|
noDataHint: '已添加的下载任务会显示在这里',
|
||||||
|
loadFailed: '下载历史加载失败',
|
||||||
|
deleteFailed: '下载历史删除失败',
|
||||||
|
},
|
||||||
siteUserData: {
|
siteUserData: {
|
||||||
title: '站点用户数据',
|
title: '站点用户数据',
|
||||||
updateTime: '更新时间',
|
updateTime: '更新时间',
|
||||||
|
|||||||
@@ -3261,6 +3261,15 @@ export default {
|
|||||||
noData: '沒有已完成的訂閱',
|
noData: '沒有已完成的訂閱',
|
||||||
noDataHint: '完成的訂閱會顯示在這裡',
|
noDataHint: '完成的訂閱會顯示在這裡',
|
||||||
},
|
},
|
||||||
|
downloadHistory: {
|
||||||
|
title: '下載歷史',
|
||||||
|
actions: '下載歷史操作',
|
||||||
|
unknownTitle: '未知媒體',
|
||||||
|
noData: '沒有下載歷史',
|
||||||
|
noDataHint: '已添加的下載任務會顯示在這裡',
|
||||||
|
loadFailed: '下載歷史載入失敗',
|
||||||
|
deleteFailed: '下載歷史刪除失敗',
|
||||||
|
},
|
||||||
siteUserData: {
|
siteUserData: {
|
||||||
title: '站點用戶數據',
|
title: '站點用戶數據',
|
||||||
updateTime: '更新時間',
|
updateTime: '更新時間',
|
||||||
|
|||||||
@@ -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'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user