mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
fix(download): redesign downloading card (#633)
This commit is contained in:
@@ -79,7 +79,7 @@
|
|||||||
},
|
},
|
||||||
"src/api/types.ts": {
|
"src/api/types.ts": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 38
|
"count": 39
|
||||||
},
|
},
|
||||||
"@typescript-eslint/no-wrapper-object-types": {
|
"@typescript-eslint/no-wrapper-object-types": {
|
||||||
"count": 2
|
"count": 2
|
||||||
|
|||||||
+1
-26
@@ -672,26 +672,7 @@ export interface SiteUserData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 正在下载
|
// 正在下载
|
||||||
export interface DownloadingMediaInfo {
|
|
||||||
// TMDB ID
|
|
||||||
tmdbid?: number
|
|
||||||
// 类型:电影、电视剧
|
|
||||||
type?: string
|
|
||||||
// 识别后的标题
|
|
||||||
title?: string
|
|
||||||
// 季
|
|
||||||
season?: string
|
|
||||||
// 集
|
|
||||||
episode?: string
|
|
||||||
// 海报
|
|
||||||
image?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DownloadingInfo {
|
export interface DownloadingInfo {
|
||||||
// 下载器名称
|
|
||||||
downloader?: string
|
|
||||||
// 来源站点
|
|
||||||
site_name?: string
|
|
||||||
// HASH
|
// HASH
|
||||||
hash?: string
|
hash?: string
|
||||||
// 种子名称
|
// 种子名称
|
||||||
@@ -712,14 +693,8 @@ export interface DownloadingInfo {
|
|||||||
dlspeed?: string
|
dlspeed?: string
|
||||||
// 上传速度
|
// 上传速度
|
||||||
upspeed?: string
|
upspeed?: string
|
||||||
// 下载器分类
|
|
||||||
category?: string
|
|
||||||
// 下载器标签
|
|
||||||
tags?: string
|
|
||||||
// Tracker 地址
|
|
||||||
trackers?: string[]
|
|
||||||
// 媒体信息
|
// 媒体信息
|
||||||
media?: DownloadingMediaInfo
|
media: { [key: string]: any }
|
||||||
// 下载用户ID
|
// 下载用户ID
|
||||||
userid?: string
|
userid?: string
|
||||||
// 下载用户名称
|
// 下载用户名称
|
||||||
|
|||||||
@@ -2,26 +2,27 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { ApiResponse, DownloadingInfo } from '@/api/types'
|
import type { ApiResponse, DownloadingInfo } from '@/api/types'
|
||||||
import { formatFileSize } from '@/@core/utils/formatters'
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
import noImage from '@images/no-image.jpeg'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 输入参数
|
/** 卡片使用的下载任务信息,兼容接口已经返回但公共类型尚未声明的来源站点。 */
|
||||||
|
interface DownloadingCardInfo extends DownloadingInfo {
|
||||||
|
site_name?: string
|
||||||
|
trackers?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 正在下载任务卡片,负责展示任务状态并提供暂停、继续和删除操作。 */
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
info: Object as PropType<DownloadingInfo>,
|
info: Object as PropType<DownloadingCardInfo>,
|
||||||
downloaderName: String,
|
downloaderName: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 是否显示卡片
|
// 卡片在删除成功后就地隐藏,等待外层轮询同步任务列表。
|
||||||
const cardState = ref(true)
|
const cardState = ref(true)
|
||||||
|
|
||||||
// 当前操作,避免轮询刷新期间重复触发控制请求。
|
|
||||||
const pendingAction = ref<'delete' | 'toggle' | null>(null)
|
const pendingAction = ref<'delete' | 'toggle' | null>(null)
|
||||||
|
|
||||||
const media = computed(() => props.info?.media ?? {})
|
|
||||||
|
|
||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
|
const media = computed(() => props.info?.media ?? {})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => media.value.image,
|
() => media.value.image,
|
||||||
@@ -30,12 +31,8 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const imageUrl = computed(() => {
|
const hasPosterImage = computed(() => Boolean(media.value.image && !imageLoadError.value))
|
||||||
if (!media.value.image || imageLoadError.value) return noImage
|
|
||||||
return media.value.image
|
|
||||||
})
|
|
||||||
|
|
||||||
// 识别信息可能不完整,依次回退到解析名称和原始任务名。
|
|
||||||
const mediaTitle = computed(() => media.value.title || props.info?.name || props.info?.title || t('common.unknown'))
|
const mediaTitle = computed(() => media.value.title || props.info?.name || props.info?.title || t('common.unknown'))
|
||||||
|
|
||||||
const episodeText = computed(() => {
|
const episodeText = computed(() => {
|
||||||
@@ -46,22 +43,23 @@ const episodeText = computed(() => {
|
|||||||
const titleMetaText = computed(() => [props.info?.year?.trim(), episodeText.value].filter(Boolean).join(' · '))
|
const titleMetaText = computed(() => [props.info?.year?.trim(), episodeText.value].filter(Boolean).join(' · '))
|
||||||
|
|
||||||
const mediaTypeText = computed(() => {
|
const mediaTypeText = computed(() => {
|
||||||
const type = media.value.type?.trim()
|
const type = String(media.value.type || '').trim()
|
||||||
if (type === '电影' || type?.toLowerCase() === 'movie') return t('mediaType.movie')
|
if (type === '电影' || type.toLowerCase() === 'movie') return t('mediaType.movie')
|
||||||
if (type === '电视剧' || type?.toLowerCase() === 'tv') return t('mediaType.tv')
|
if (type === '电视剧' || type.toLowerCase() === 'tv') return t('mediaType.tv')
|
||||||
return type || t('mediaType.unknown')
|
if (type) return type
|
||||||
|
if (media.value.season || media.value.episode || props.info?.season_episode) return t('mediaType.tv')
|
||||||
|
return media.value.title ? t('mediaType.movie') : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const mediaTypeIcon = computed(() => {
|
const mediaTypeIcon = computed(() => {
|
||||||
const type = media.value.type?.trim().toLowerCase()
|
const type = String(media.value.type || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
if (type === '电影' || type === 'movie') return 'mdi-movie-outline'
|
if (type === '电影' || type === 'movie') return 'mdi-movie-outline'
|
||||||
if (type === '电视剧' || type === 'tv') return 'mdi-television-classic'
|
if (type === '电视剧' || type === 'tv') return 'mdi-television-classic'
|
||||||
return 'mdi-help-circle-outline'
|
return 'mdi-play-box-outline'
|
||||||
})
|
})
|
||||||
|
|
||||||
const sourceSiteText = computed(() => props.info?.site_name?.trim() || t('downloading.unknownSite'))
|
|
||||||
|
|
||||||
// 规范异常进度,避免进度条或百分比超出卡片。
|
|
||||||
const progressValue = computed(() => {
|
const progressValue = computed(() => {
|
||||||
const progress = Number(props.info?.progress ?? 0)
|
const progress = Number(props.info?.progress ?? 0)
|
||||||
if (!Number.isFinite(progress)) return 0
|
if (!Number.isFinite(progress)) return 0
|
||||||
@@ -69,21 +67,37 @@ const progressValue = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const progressText = computed(() => `${Math.round(progressValue.value)}%`)
|
const progressText = computed(() => `${Math.round(progressValue.value)}%`)
|
||||||
|
const sizeText = computed(() => formatFileSize(props.info?.size || 0))
|
||||||
|
const remainingTimeText = computed(() => props.info?.left_time?.trim() || '--')
|
||||||
|
|
||||||
|
/** 从 Tracker 地址中仅提取可展示的主机名,避免暴露路径、查询参数或 passkey。 */
|
||||||
|
function getTrackerHostname(tracker?: string) {
|
||||||
|
if (!tracker) return ''
|
||||||
|
try {
|
||||||
|
return new URL(tracker).hostname.replace(/^www\./, '')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceSiteText = computed(() => {
|
||||||
|
const siteName = String(props.info?.site_name || media.value.site_name || '').trim()
|
||||||
|
if (siteName) return siteName
|
||||||
|
return props.info?.trackers?.map(getTrackerHostname).find(Boolean) || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 为下载器返回的速率补齐单位,并兼容已经包含每秒单位的值。 */
|
||||||
function formatSpeed(speed?: string) {
|
function formatSpeed(speed?: string) {
|
||||||
const value = speed?.trim() || '0 B'
|
const value = speed?.trim() || '0 B'
|
||||||
return /\/s$/i.test(value) ? value : `${value}/s`
|
return /\/s$/i.test(value) ? value : `${value}/s`
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeText = computed(() => formatFileSize(props.info?.size || 0))
|
|
||||||
const downloadSpeedText = computed(() => formatSpeed(props.info?.dlspeed))
|
const downloadSpeedText = computed(() => formatSpeed(props.info?.dlspeed))
|
||||||
const uploadSpeedText = computed(() => formatSpeed(props.info?.upspeed))
|
const uploadSpeedText = computed(() => formatSpeed(props.info?.upspeed))
|
||||||
const remainingTimeText = computed(() => props.info?.left_time?.trim() || t('downloading.calculating'))
|
|
||||||
|
|
||||||
// 下载状态
|
// 下载状态跟随轮询数据变化,操作成功时也会立即响应。
|
||||||
const isDownloading = ref(props.info?.state === 'downloading')
|
const isDownloading = ref(props.info?.state === 'downloading')
|
||||||
|
|
||||||
// 监听props.info?.state的变化
|
|
||||||
watch(
|
watch(
|
||||||
() => props.info?.state,
|
() => props.info?.state,
|
||||||
newValue => {
|
newValue => {
|
||||||
@@ -91,7 +105,7 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 下载状态控制
|
/** 暂停或继续当前任务,并防止请求完成前重复触发。 */
|
||||||
async function toggleDownload() {
|
async function toggleDownload() {
|
||||||
if (pendingAction.value) return
|
if (pendingAction.value) return
|
||||||
|
|
||||||
@@ -112,7 +126,7 @@ async function toggleDownload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除下载任务
|
/** 删除当前下载任务,并仅在业务请求成功后隐藏卡片。 */
|
||||||
async function deleteDownload() {
|
async function deleteDownload() {
|
||||||
if (pendingAction.value) return
|
if (pendingAction.value) return
|
||||||
|
|
||||||
@@ -141,33 +155,40 @@ async function deleteDownload() {
|
|||||||
:class="{
|
:class="{
|
||||||
'app-hover-lift-card--hovering': hover.isHovering,
|
'app-hover-lift-card--hovering': hover.isHovering,
|
||||||
'downloading-card--hovering': hover.isHovering,
|
'downloading-card--hovering': hover.isHovering,
|
||||||
|
'downloading-card--no-image': !hasPosterImage,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="downloading-card__poster">
|
<div v-if="hasPosterImage" class="downloading-card__poster">
|
||||||
<VImg :src="imageUrl" class="downloading-card__image" cover position="top" @error="imageLoadError = true">
|
<VImg
|
||||||
|
:src="media.image"
|
||||||
|
class="downloading-card__image"
|
||||||
|
cover
|
||||||
|
position="top"
|
||||||
|
@error="imageLoadError = true"
|
||||||
|
>
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<div class="downloading-card__image-placeholder">
|
<VSkeletonLoader class="downloading-card__image-loader h-full" />
|
||||||
<VSkeletonLoader class="h-full" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</VImg>
|
</VImg>
|
||||||
<div class="downloading-card__poster-scrim" />
|
<div class="downloading-card__poster-edge" />
|
||||||
<div class="downloading-card__poster-progress">{{ progressText }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="downloading-card__body">
|
<VCardText class="downloading-card__body">
|
||||||
<div class="downloading-card__chips">
|
<div class="downloading-card__chips">
|
||||||
<VChip color="primary" size="x-small" variant="tonal" :prepend-icon="mediaTypeIcon">
|
<VChip v-if="mediaTypeText" :prepend-icon="mediaTypeIcon" color="primary" size="x-small" variant="tonal">
|
||||||
{{ mediaTypeText }}
|
{{ mediaTypeText }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip size="x-small" variant="tonal" prepend-icon="mdi-web">
|
<VChip v-if="sourceSiteText" prepend-icon="mdi-web" size="x-small" variant="tonal">
|
||||||
{{ sourceSiteText }}
|
{{ sourceSiteText }}
|
||||||
</VChip>
|
</VChip>
|
||||||
|
<VChip v-else prepend-icon="mdi-harddisk" size="x-small" variant="tonal">
|
||||||
|
{{ sizeText }}
|
||||||
|
</VChip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="downloading-card__heading">
|
<div class="downloading-card__heading">
|
||||||
<div class="downloading-card__title" :title="mediaTitle">
|
<div class="downloading-card__title" :title="mediaTitle">
|
||||||
{{ mediaTitle }}
|
<span>{{ mediaTitle }}</span>
|
||||||
<span v-if="titleMetaText" class="downloading-card__title-meta">{{ titleMetaText }}</span>
|
<span v-if="titleMetaText" class="downloading-card__title-meta">{{ titleMetaText }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="downloading-card__torrent-title" :title="props.info?.title">
|
<div class="downloading-card__torrent-title" :title="props.info?.title">
|
||||||
@@ -175,75 +196,69 @@ async function deleteDownload() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="downloading-card__progress">
|
<div v-if="progressValue > 0" class="downloading-card__progress">
|
||||||
<div class="downloading-card__progress-label">
|
<div class="downloading-card__progress-label">
|
||||||
<span>{{ isDownloading ? t('downloading.statusDownloading') : t('downloading.statusPaused') }}</span>
|
<span>
|
||||||
<span>{{ progressText }}</span>
|
{{ isDownloading ? t('common.download') : t('common.pause') }}
|
||||||
|
<span class="downloading-card__progress-separator">·</span>
|
||||||
|
{{ remainingTimeText }}
|
||||||
|
</span>
|
||||||
|
<strong>{{ progressText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<VProgressLinear
|
<VProgressLinear
|
||||||
:aria-label="t('downloading.progress')"
|
:aria-label="t('common.download')"
|
||||||
:model-value="progressValue"
|
:model-value="progressValue"
|
||||||
color="primary"
|
:color="isDownloading ? 'success' : 'warning'"
|
||||||
bg-color="surface-variant"
|
bg-color="surface-variant"
|
||||||
height="6"
|
height="6"
|
||||||
rounded
|
rounded
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="downloading-card__stats">
|
<div class="downloading-card__footer">
|
||||||
<div class="downloading-card__stat">
|
<div class="downloading-card__speeds">
|
||||||
<VIcon icon="mdi-harddisk" size="16" />
|
<div class="downloading-card__speed downloading-card__speed--download">
|
||||||
<span class="downloading-card__stat-label">{{ t('downloading.size') }}</span>
|
<VIcon icon="mdi-arrow-down" size="16" />
|
||||||
<strong :title="sizeText">{{ sizeText }}</strong>
|
<strong :title="downloadSpeedText">{{ downloadSpeedText }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="downloading-card__speed downloading-card__speed--upload">
|
||||||
|
<VIcon icon="mdi-arrow-up" size="16" />
|
||||||
|
<strong :title="uploadSpeedText">{{ uploadSpeedText }}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="downloading-card__stat downloading-card__stat--download">
|
|
||||||
<VIcon icon="mdi-arrow-down" size="16" />
|
|
||||||
<span class="downloading-card__stat-label">{{ t('downloading.downloadSpeed') }}</span>
|
|
||||||
<strong :title="downloadSpeedText">{{ downloadSpeedText }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="downloading-card__stat downloading-card__stat--upload">
|
|
||||||
<VIcon icon="mdi-arrow-up" size="16" />
|
|
||||||
<span class="downloading-card__stat-label">{{ t('downloading.uploadSpeed') }}</span>
|
|
||||||
<strong :title="uploadSpeedText">{{ uploadSpeedText }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<VCardActions class="downloading-card__actions pa-0">
|
<VCardActions class="downloading-card__actions pa-0">
|
||||||
<div class="downloading-card__remaining">
|
|
||||||
<VIcon icon="mdi-timer-sand" size="16" />
|
|
||||||
<span>{{ t('downloading.remainingTime') }}</span>
|
|
||||||
<strong>{{ remainingTimeText }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="downloading-card__buttons">
|
|
||||||
<VBtn
|
<VBtn
|
||||||
:aria-label="isDownloading ? t('downloading.pauseTask') : t('downloading.resumeTask')"
|
:aria-label="isDownloading ? t('common.pause') : t('common.download')"
|
||||||
:disabled="pendingAction === 'delete'"
|
:disabled="pendingAction === 'delete'"
|
||||||
:icon="isDownloading ? 'mdi-pause' : 'mdi-play'"
|
icon
|
||||||
:loading="pendingAction === 'toggle'"
|
:loading="pendingAction === 'toggle'"
|
||||||
color="primary"
|
color="primary"
|
||||||
size="small"
|
size="small"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
@click="toggleDownload"
|
@click="toggleDownload"
|
||||||
>
|
>
|
||||||
|
<VIcon :icon="isDownloading ? 'mdi-pause' : 'mdi-play'" />
|
||||||
<VTooltip activator="parent" location="top">
|
<VTooltip activator="parent" location="top">
|
||||||
{{ isDownloading ? t('downloading.pauseTask') : t('downloading.resumeTask') }}
|
{{ isDownloading ? t('common.pause') : t('common.download') }}
|
||||||
</VTooltip>
|
</VTooltip>
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn
|
<VBtn
|
||||||
:aria-label="t('downloading.deleteTask')"
|
:aria-label="t('common.delete')"
|
||||||
:disabled="pendingAction === 'toggle'"
|
:disabled="pendingAction === 'toggle'"
|
||||||
:loading="pendingAction === 'delete'"
|
:loading="pendingAction === 'delete'"
|
||||||
color="error"
|
color="error"
|
||||||
icon="mdi-trash-can-outline"
|
icon
|
||||||
size="small"
|
size="small"
|
||||||
variant="text"
|
variant="text"
|
||||||
@click="deleteDownload"
|
@click="deleteDownload"
|
||||||
>
|
>
|
||||||
<VTooltip activator="parent" location="top">{{ t('downloading.deleteTask') }}</VTooltip>
|
<VIcon icon="mdi-trash-can-outline" />
|
||||||
|
<VTooltip activator="parent" location="top">{{ t('common.delete') }}</VTooltip>
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</div>
|
</VCardActions>
|
||||||
</VCardActions>
|
</div>
|
||||||
</div>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -255,27 +270,26 @@ async function deleteDownload() {
|
|||||||
|
|
||||||
.downloading-card-hover-area {
|
.downloading-card-hover-area {
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
|
container-type: inline-size;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card {
|
.downloading-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-block-size: 15.5rem;
|
min-block-size: 13rem;
|
||||||
grid-template-columns: 6.75rem minmax(0, 1fr);
|
color: rgb(var(--v-theme-on-surface));
|
||||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
grid-template-columns: 8.25rem minmax(0, 1fr);
|
||||||
background: rgb(var(--v-theme-surface));
|
|
||||||
container-type: inline-size;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__poster {
|
.downloading-card__poster {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-block-size: 100%;
|
min-block-size: 13rem;
|
||||||
background: rgb(var(--v-theme-surface-variant));
|
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__image,
|
.downloading-card__image,
|
||||||
.downloading-card__image-placeholder {
|
.downloading-card__image-loader {
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
}
|
}
|
||||||
@@ -285,40 +299,22 @@ async function deleteDownload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card--hovering .downloading-card__image :deep(.v-img__img) {
|
.downloading-card--hovering .downloading-card__image :deep(.v-img__img) {
|
||||||
transform: scale(1.04);
|
transform: scale(1.035);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__poster-scrim {
|
.downloading-card__poster-edge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
background: linear-gradient(90deg, rgba(var(--v-theme-surface), 0) 72%, rgba(var(--v-theme-surface), 0.38));
|
||||||
background: linear-gradient(180deg, rgba(4, 8, 14, 4%) 35%, rgba(4, 8, 14, 76%) 100%);
|
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__poster-progress {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 2;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 18%);
|
|
||||||
border-radius: 999px;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
background: rgba(10, 15, 24, 62%);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
inset-block-end: 0.75rem;
|
|
||||||
inset-inline-start: 0.75rem;
|
|
||||||
line-height: 1;
|
|
||||||
padding-block: 0.38rem;
|
|
||||||
padding-inline: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloading-card__body {
|
.downloading-card__body {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
padding: 1rem;
|
padding: 1rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__chips {
|
.downloading-card__chips {
|
||||||
@@ -329,7 +325,7 @@ async function deleteDownload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__chips :deep(.v-chip) {
|
.downloading-card__chips :deep(.v-chip) {
|
||||||
max-inline-size: 50%;
|
max-inline-size: calc(50% - 0.2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__chips :deep(.v-chip__content) {
|
.downloading-card__chips :deep(.v-chip__content) {
|
||||||
@@ -343,32 +339,37 @@ async function deleteDownload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__title {
|
.downloading-card__title {
|
||||||
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
text-overflow: ellipsis;
|
overflow-wrap: anywhere;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__title-meta {
|
.downloading-card__title-meta {
|
||||||
margin-inline-start: 0.35rem;
|
margin-inline-start: 0.35rem;
|
||||||
color: rgb(var(--v-theme-primary));
|
color: rgb(var(--v-theme-primary));
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__torrent-title {
|
.downloading-card__torrent-title {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-block-start: 0.2rem;
|
margin-block-start: 0.25rem;
|
||||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
line-height: 1.35;
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__progress {
|
.downloading-card__progress {
|
||||||
@@ -377,120 +378,145 @@ async function deleteDownload() {
|
|||||||
|
|
||||||
.downloading-card__progress-label {
|
.downloading-card__progress-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-inline-size: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-block-end: 0.35rem;
|
margin-block-end: 0.4rem;
|
||||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__progress-label span:last-child {
|
.downloading-card__progress-label > span {
|
||||||
color: rgb(var(--v-theme-primary));
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloading-card__stats {
|
|
||||||
display: grid;
|
|
||||||
min-inline-size: 0;
|
|
||||||
padding-block: 0.55rem;
|
|
||||||
border-block: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloading-card__stat {
|
|
||||||
display: grid;
|
|
||||||
min-inline-size: 0;
|
|
||||||
align-items: center;
|
|
||||||
column-gap: 0.3rem;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloading-card__stat + .downloading-card__stat {
|
|
||||||
padding-inline-start: 0.55rem;
|
|
||||||
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloading-card__stat .v-icon {
|
|
||||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloading-card__stat--download .v-icon {
|
|
||||||
color: rgb(var(--v-theme-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloading-card__stat--upload .v-icon {
|
|
||||||
color: rgb(var(--v-theme-success));
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloading-card__stat-label,
|
|
||||||
.downloading-card__stat strong {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__stat-label {
|
.downloading-card__progress-label strong {
|
||||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
flex: 0 0 auto;
|
||||||
font-size: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloading-card__stat strong {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
margin-block-start: 0.18rem;
|
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
font-size: 0.72rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-weight: 650;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__actions {
|
.downloading-card__progress-separator {
|
||||||
|
padding-inline: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-card__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-block-size: 2rem;
|
min-inline-size: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-block-start: auto;
|
margin-block-start: auto;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__remaining {
|
.downloading-card__speeds {
|
||||||
|
display: flex;
|
||||||
|
min-inline-size: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 0.8rem;
|
||||||
|
row-gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-card__speed {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-card__speed strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
gap: 0.3rem;
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 650;
|
||||||
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__remaining strong {
|
.downloading-card__speed--download .v-icon {
|
||||||
overflow: hidden;
|
color: rgb(var(--v-theme-info));
|
||||||
color: rgb(var(--v-theme-on-surface));
|
|
||||||
font-weight: 650;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__buttons {
|
.downloading-card__speed--upload .v-icon {
|
||||||
|
color: rgb(var(--v-theme-success));
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-card__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
gap: 0.1rem;
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (width <= 23rem) {
|
@container (width <= 25rem) {
|
||||||
|
.downloading-card {
|
||||||
|
min-block-size: 12rem;
|
||||||
|
grid-template-columns: 6.75rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-card__poster {
|
||||||
|
min-block-size: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
.downloading-card__body {
|
.downloading-card__body {
|
||||||
gap: 0.55rem;
|
gap: 0.5rem;
|
||||||
padding: 0.8rem;
|
padding: 0.75rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__stat + .downloading-card__stat {
|
.downloading-card__chips {
|
||||||
padding-inline-start: 0.4rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__remaining > span {
|
.downloading-card__title {
|
||||||
display: none;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloading-card__torrent-title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-card__speeds {
|
||||||
|
column-gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-card__speed strong {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (width <= 21rem) {
|
||||||
|
.downloading-card {
|
||||||
|
grid-template-columns: 6.25rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-card__body {
|
||||||
|
padding-inline: 0.65rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-card__chips :deep(.v-chip) {
|
||||||
|
max-inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-card__chips :deep(.v-chip:first-child:last-child) {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-card__actions :deep(.v-btn) {
|
||||||
|
block-size: 2.25rem;
|
||||||
|
inline-size: 2.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-card.downloading-card--no-image {
|
||||||
|
min-block-size: 0;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -16,17 +16,14 @@ function downloading(overrides: Partial<DownloadingInfo> = {}): DownloadingInfo
|
|||||||
image: 'https://images.example.com/poster.jpg',
|
image: 'https://images.example.com/poster.jpg',
|
||||||
season: 'S01',
|
season: 'S01',
|
||||||
title: '测试媒体',
|
title: '测试媒体',
|
||||||
type: '电视剧',
|
|
||||||
},
|
},
|
||||||
name: 'fallback-name',
|
name: 'fallback-name',
|
||||||
progress: 40,
|
progress: 40,
|
||||||
season_episode: 'S01E02',
|
season_episode: 'S01E02',
|
||||||
size: 1024,
|
size: 1024,
|
||||||
site_name: '馒头',
|
|
||||||
state: 'downloading',
|
state: 'downloading',
|
||||||
title: '下载任务标题',
|
title: '下载任务标题',
|
||||||
upspeed: '1 MiB',
|
upspeed: '1 MiB',
|
||||||
year: '2026',
|
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,18 +49,10 @@ describe('DownloadingCard display and pause state', () => {
|
|||||||
const { container } = await renderCard()
|
const { container } = await renderCard()
|
||||||
|
|
||||||
expect(screen.getByText(/测试媒体/)).toBeInTheDocument()
|
expect(screen.getByText(/测试媒体/)).toBeInTheDocument()
|
||||||
expect(screen.getByText(/2026 · S01 E02/)).toBeInTheDocument()
|
expect(screen.getByText(/S01 E02/)).toBeInTheDocument()
|
||||||
expect(screen.getByText('下载任务标题')).toBeInTheDocument()
|
expect(screen.getByText('下载任务标题')).toBeInTheDocument()
|
||||||
expect(screen.getByText('电视剧')).toBeInTheDocument()
|
expect(screen.getByText(/1 小时/)).toBeInTheDocument()
|
||||||
expect(screen.getByText('馒头')).toBeInTheDocument()
|
expect(container.querySelector('.v-card-text .v-progress-linear')).toBeInTheDocument()
|
||||||
expect(screen.getByText('1.00 KB')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('2 MiB/s')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('1 MiB/s')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('1 小时')).toBeInTheDocument()
|
|
||||||
expect(screen.getAllByText('40%')).toHaveLength(2)
|
|
||||||
expect(container.querySelector('.downloading-card__progress .v-progress-linear')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: '暂停任务' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: '删除任务' })).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to the task name and season string when media recognition is incomplete', async () => {
|
it('falls back to the task name and season string when media recognition is incomplete', async () => {
|
||||||
@@ -73,32 +62,13 @@ describe('DownloadingCard display and pause state', () => {
|
|||||||
name: '未识别任务',
|
name: '未识别任务',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
season_episode: 'S03E04',
|
season_episode: 'S03E04',
|
||||||
site_name: undefined,
|
|
||||||
state: 'stopped',
|
state: 'stopped',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByText(/未识别任务/)).toBeInTheDocument()
|
expect(screen.getByText(/未识别任务/)).toBeInTheDocument()
|
||||||
expect(screen.getByText(/S03E04/)).toBeInTheDocument()
|
expect(screen.getByText(/S03E04/)).toBeInTheDocument()
|
||||||
expect(screen.getByText('未知')).toBeInTheDocument()
|
expect(container.querySelector('.v-card-text .v-progress-linear')).not.toBeInTheDocument()
|
||||||
expect(screen.getByText('未知站点')).toBeInTheDocument()
|
|
||||||
expect(screen.getAllByText('0%')).toHaveLength(2)
|
|
||||||
expect(container.querySelector('.downloading-card__progress .v-progress-linear')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: '继续任务' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clamps invalid progress and avoids appending a duplicate speed unit', async () => {
|
|
||||||
await renderCard(
|
|
||||||
downloading({
|
|
||||||
dlspeed: '3 MiB/s',
|
|
||||||
progress: 140,
|
|
||||||
upspeed: '',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getAllByText('100%')).toHaveLength(2)
|
|
||||||
expect(screen.getByText('3 MiB/s')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('0 B/s')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the current operation and downloader name, changing state only on business success', async () => {
|
it('uses the current operation and downloader name, changing state only on business success', async () => {
|
||||||
|
|||||||
@@ -1277,18 +1277,6 @@ export default {
|
|||||||
title: 'Downloading',
|
title: 'Downloading',
|
||||||
noTask: 'No Task',
|
noTask: 'No Task',
|
||||||
noTaskDescription: 'Downloading tasks will be displayed here.',
|
noTaskDescription: 'Downloading tasks will be displayed here.',
|
||||||
unknownSite: 'Unknown site',
|
|
||||||
statusDownloading: 'Downloading',
|
|
||||||
statusPaused: 'Paused',
|
|
||||||
progress: 'Download progress',
|
|
||||||
size: 'Size',
|
|
||||||
downloadSpeed: 'Download',
|
|
||||||
uploadSpeed: 'Upload',
|
|
||||||
remainingTime: 'Remaining',
|
|
||||||
calculating: 'Calculating',
|
|
||||||
pauseTask: 'Pause task',
|
|
||||||
resumeTask: 'Resume task',
|
|
||||||
deleteTask: 'Delete task',
|
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
searchResults: 'Resource Search Results',
|
searchResults: 'Resource Search Results',
|
||||||
|
|||||||
@@ -1268,18 +1268,6 @@ export default {
|
|||||||
title: '下载',
|
title: '下载',
|
||||||
noTask: '没有任务',
|
noTask: '没有任务',
|
||||||
noTaskDescription: '正在下载的任务将会显示在这里。',
|
noTaskDescription: '正在下载的任务将会显示在这里。',
|
||||||
unknownSite: '未知站点',
|
|
||||||
statusDownloading: '下载中',
|
|
||||||
statusPaused: '已暂停',
|
|
||||||
progress: '下载进度',
|
|
||||||
size: '大小',
|
|
||||||
downloadSpeed: '下载',
|
|
||||||
uploadSpeed: '上传',
|
|
||||||
remainingTime: '剩余',
|
|
||||||
calculating: '计算中',
|
|
||||||
pauseTask: '暂停任务',
|
|
||||||
resumeTask: '继续任务',
|
|
||||||
deleteTask: '删除任务',
|
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
searchResults: '资源搜索结果',
|
searchResults: '资源搜索结果',
|
||||||
|
|||||||
@@ -1266,18 +1266,6 @@ export default {
|
|||||||
title: '下載',
|
title: '下載',
|
||||||
noTask: '沒有任務',
|
noTask: '沒有任務',
|
||||||
noTaskDescription: '正在下載的任務將會顯示在這裡。',
|
noTaskDescription: '正在下載的任務將會顯示在這裡。',
|
||||||
unknownSite: '未知站點',
|
|
||||||
statusDownloading: '下載中',
|
|
||||||
statusPaused: '已暫停',
|
|
||||||
progress: '下載進度',
|
|
||||||
size: '大小',
|
|
||||||
downloadSpeed: '下載',
|
|
||||||
uploadSpeed: '上傳',
|
|
||||||
remainingTime: '剩餘',
|
|
||||||
calculating: '計算中',
|
|
||||||
pauseTask: '暫停任務',
|
|
||||||
resumeTask: '繼續任務',
|
|
||||||
deleteTask: '刪除任務',
|
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
searchResults: '資源搜索結果',
|
searchResults: '資源搜索結果',
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ useKeepAliveRefresh(fetchData, {
|
|||||||
v-if="filteredDataList.length > 0"
|
v-if="filteredDataList.length > 0"
|
||||||
:items="filteredDataList"
|
:items="filteredDataList"
|
||||||
:get-item-key="item => item.hash || item.name"
|
:get-item-key="item => item.hash || item.name"
|
||||||
:min-item-width="360"
|
:min-item-width="320"
|
||||||
:estimated-item-height="248"
|
:estimated-item-height="230"
|
||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<DownloadingCard :info="item" :downloader-name="props.name" />
|
<DownloadingCard :info="item" :downloader-name="props.name" />
|
||||||
|
|||||||
Reference in New Issue
Block a user