From cef44692a83094885fbe88f88db650798c672403 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 3 Aug 2026 18:49:23 +0800 Subject: [PATCH] feat(download): redesign active download cards (#632) --- eslint-suppressions.json | 4 +- src/api/types.ts | 27 +- src/components/cards/DownloadingCard.vue | 449 ++++++++++++++++-- .../cards/__tests__/DownloadingCard.spec.ts | 38 +- src/locales/en-US.ts | 12 + src/locales/zh-CN.ts | 12 + src/locales/zh-TW.ts | 12 + src/views/reorganize/DownloadingListView.vue | 4 +- 8 files changed, 504 insertions(+), 54 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 774f4a7d..ca90cb17 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -79,7 +79,7 @@ }, "src/api/types.ts": { "@typescript-eslint/no-explicit-any": { - "count": 39 + "count": 38 }, "@typescript-eslint/no-wrapper-object-types": { "count": 2 @@ -1092,4 +1092,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/src/api/types.ts b/src/api/types.ts index 57cc4990..3f7cdfbf 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -672,7 +672,26 @@ export interface SiteUserData { } // 正在下载 +export interface DownloadingMediaInfo { + // TMDB ID + tmdbid?: number + // 类型:电影、电视剧 + type?: string + // 识别后的标题 + title?: string + // 季 + season?: string + // 集 + episode?: string + // 海报 + image?: string +} + export interface DownloadingInfo { + // 下载器名称 + downloader?: string + // 来源站点 + site_name?: string // HASH hash?: string // 种子名称 @@ -693,8 +712,14 @@ export interface DownloadingInfo { dlspeed?: string // 上传速度 upspeed?: string + // 下载器分类 + category?: string + // 下载器标签 + tags?: string + // Tracker 地址 + trackers?: string[] // 媒体信息 - media: { [key: string]: any } + media?: DownloadingMediaInfo // 下载用户ID userid?: string // 下载用户名称 diff --git a/src/components/cards/DownloadingCard.vue b/src/components/cards/DownloadingCard.vue index 85d539bf..0b63c493 100644 --- a/src/components/cards/DownloadingCard.vue +++ b/src/components/cards/DownloadingCard.vue @@ -2,6 +2,8 @@ import api from '@/api' import type { ApiResponse, DownloadingInfo } from '@/api/types' import { formatFileSize } from '@/@core/utils/formatters' +import noImage from '@images/no-image.jpeg' +import { useI18n } from 'vue-i18n' // 输入参数 const props = defineProps({ @@ -9,20 +11,74 @@ const props = defineProps({ downloaderName: String, }) +const { t } = useI18n() + // 是否显示卡片 const cardState = ref(true) -// 进度条 -function getPercentage() { - return props.info?.progress ?? 0 +// 当前操作,避免轮询刷新期间重复触发控制请求。 +const pendingAction = ref<'delete' | 'toggle' | null>(null) + +const media = computed(() => props.info?.media ?? {}) + +const imageLoadError = ref(false) + +watch( + () => media.value.image, + () => { + imageLoadError.value = false + }, +) + +const imageUrl = computed(() => { + 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 episodeText = computed(() => { + const recognizedEpisode = [media.value.season, media.value.episode].filter(Boolean).join(' ') + return recognizedEpisode || props.info?.season_episode || '' +}) + +const titleMetaText = computed(() => [props.info?.year?.trim(), episodeText.value].filter(Boolean).join(' · ')) + +const mediaTypeText = computed(() => { + const type = media.value.type?.trim() + if (type === '电影' || type?.toLowerCase() === 'movie') return t('mediaType.movie') + if (type === '电视剧' || type?.toLowerCase() === 'tv') return t('mediaType.tv') + return type || t('mediaType.unknown') +}) + +const mediaTypeIcon = computed(() => { + const type = media.value.type?.trim().toLowerCase() + if (type === '电影' || type === 'movie') return 'mdi-movie-outline' + if (type === '电视剧' || type === 'tv') return 'mdi-television-classic' + return 'mdi-help-circle-outline' +}) + +const sourceSiteText = computed(() => props.info?.site_name?.trim() || t('downloading.unknownSite')) + +// 规范异常进度,避免进度条或百分比超出卡片。 +const progressValue = computed(() => { + const progress = Number(props.info?.progress ?? 0) + if (!Number.isFinite(progress)) return 0 + return Math.min(Math.max(progress, 0), 100) +}) + +const progressText = computed(() => `${Math.round(progressValue.value)}%`) + +function formatSpeed(speed?: string) { + const value = speed?.trim() || '0 B' + return /\/s$/i.test(value) ? value : `${value}/s` } -// 速度 -function getSpeedText() { - return `${formatFileSize(props.info?.size || 0)} ↑ ${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${ - props.info?.left_time - }` -} +const sizeText = computed(() => formatFileSize(props.info?.size || 0)) +const downloadSpeedText = computed(() => formatSpeed(props.info?.dlspeed)) +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') @@ -37,7 +93,10 @@ watch( // 下载状态控制 async function toggleDownload() { + if (pendingAction.value) return + const operation = isDownloading.value ? 'stop' : 'start' + pendingAction.value = 'toggle' try { const result: ApiResponse = await api.get(`download/${operation}/${props.info?.hash}`, { params: { @@ -48,11 +107,16 @@ async function toggleDownload() { if (result.success) isDownloading.value = !isDownloading.value } catch (error) { console.error(error) + } finally { + pendingAction.value = null } } // 删除下载任务 async function deleteDownload() { + if (pendingAction.value) return + + pendingAction.value = 'delete' try { const result: ApiResponse = await api.delete(`download/${props.info?.hash}`, { params: { name: props.downloaderName }, @@ -60,6 +124,8 @@ async function deleteDownload() { if (result.success) cardState.value = false } catch (error) { console.error(error) + } finally { + pendingAction.value = null } } @@ -71,50 +137,111 @@ async function deleteDownload() {
- +
+
{{ progressText }}
+
-
- - {{ props.info?.media.title || props.info?.name }} - {{ - props.info?.media.episode - ? `${props.info?.media.season} ${props.info?.media.episode}` - : props.info?.season_episode - }} - +
+
+ + {{ mediaTypeText }} + + + {{ sourceSiteText }} + +
- - {{ props.info?.title }} - +
+
+ {{ mediaTitle }} + {{ titleMetaText }} +
+
+ {{ props.info?.title || t('common.unknown') }} +
+
- - {{ getSpeedText() }} - +
+
+ {{ isDownloading ? t('downloading.statusDownloading') : t('downloading.statusPaused') }} + {{ progressText }} +
+ +
- - - +
+
+ + {{ t('downloading.size') }} + {{ sizeText }} +
+
+ + {{ t('downloading.downloadSpeed') }} + {{ downloadSpeedText }} +
+
+ + {{ t('downloading.uploadSpeed') }} + {{ uploadSpeedText }} +
+
- - - + +
+ + {{ t('downloading.remainingTime') }} + {{ remainingTimeText }} +
+
+ + + {{ isDownloading ? t('downloading.pauseTask') : t('downloading.resumeTask') }} + + + + {{ t('downloading.deleteTask') }} + +
@@ -127,16 +254,248 @@ async function deleteDownload() { /* stylelint-disable selector-pseudo-class-no-unknown */ .downloading-card-hover-area { + block-size: 100%; inline-size: 100%; } -.downloading-card-image { - block-size: 100%; +.downloading-card { + display: grid; + min-block-size: 15.5rem; + grid-template-columns: 6.75rem minmax(0, 1fr); + border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); + background: rgb(var(--v-theme-surface)); + container-type: inline-size; } -.downloading-card-background { - border-radius: inherit; - background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%); +.downloading-card__poster { + position: relative; + overflow: hidden; + min-block-size: 100%; + background: rgb(var(--v-theme-surface-variant)); +} + +.downloading-card__image, +.downloading-card__image-placeholder { + block-size: 100%; + inline-size: 100%; +} + +.downloading-card__image :deep(.v-img__img) { + transition: transform 0.35s ease; +} + +.downloading-card--hovering .downloading-card__image :deep(.v-img__img) { + transform: scale(1.04); +} + +.downloading-card__poster-scrim { + position: absolute; + z-index: 1; + background: linear-gradient(180deg, rgba(4, 8, 14, 4%) 35%, rgba(4, 8, 14, 76%) 100%); + inset: 0; 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 { + display: flex; + min-inline-size: 0; + flex-direction: column; + gap: 0.65rem; + padding: 1rem; +} + +.downloading-card__chips { + display: flex; + min-inline-size: 0; + align-items: center; + gap: 0.4rem; +} + +.downloading-card__chips :deep(.v-chip) { + max-inline-size: 50%; +} + +.downloading-card__chips :deep(.v-chip__content) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.downloading-card__heading { + min-inline-size: 0; +} + +.downloading-card__title { + overflow: hidden; + color: rgb(var(--v-theme-on-surface)); + font-size: 1rem; + font-weight: 700; + letter-spacing: 0; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.downloading-card__title-meta { + margin-inline-start: 0.35rem; + color: rgb(var(--v-theme-primary)); + font-size: 0.8rem; + font-weight: 650; +} + +.downloading-card__torrent-title { + display: -webkit-box; + overflow: hidden; + margin-block-start: 0.2rem; + color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); + font-size: 0.75rem; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-height: 1.35; +} + +.downloading-card__progress { + min-inline-size: 0; +} + +.downloading-card__progress-label { + display: flex; + align-items: center; + justify-content: space-between; + margin-block-end: 0.35rem; + color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); + font-size: 0.7rem; + font-weight: 600; +} + +.downloading-card__progress-label span:last-child { + 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; + text-overflow: ellipsis; + white-space: nowrap; +} + +.downloading-card__stat-label { + color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); + font-size: 0.65rem; +} + +.downloading-card__stat strong { + grid-column: 1 / -1; + margin-block-start: 0.18rem; + color: rgb(var(--v-theme-on-surface)); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + font-weight: 650; +} + +.downloading-card__actions { + display: flex; + min-block-size: 2rem; + align-items: center; + justify-content: space-between; + margin-block-start: auto; + gap: 0.5rem; +} + +.downloading-card__remaining { + display: flex; + overflow: hidden; + min-inline-size: 0; + align-items: center; + color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); + font-size: 0.7rem; + gap: 0.3rem; + white-space: nowrap; +} + +.downloading-card__remaining strong { + overflow: hidden; + color: rgb(var(--v-theme-on-surface)); + font-weight: 650; + text-overflow: ellipsis; +} + +.downloading-card__buttons { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 0.2rem; +} + +@container (width <= 23rem) { + .downloading-card__body { + gap: 0.55rem; + padding: 0.8rem; + } + + .downloading-card__stat + .downloading-card__stat { + padding-inline-start: 0.4rem; + } + + .downloading-card__remaining > span { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .downloading-card__image :deep(.v-img__img) { + transition: none; + } +} diff --git a/src/components/cards/__tests__/DownloadingCard.spec.ts b/src/components/cards/__tests__/DownloadingCard.spec.ts index f7accddd..b0d0c3eb 100644 --- a/src/components/cards/__tests__/DownloadingCard.spec.ts +++ b/src/components/cards/__tests__/DownloadingCard.spec.ts @@ -16,14 +16,17 @@ function downloading(overrides: Partial = {}): DownloadingInfo image: 'https://images.example.com/poster.jpg', season: 'S01', title: '测试媒体', + type: '电视剧', }, name: 'fallback-name', progress: 40, season_episode: 'S01E02', size: 1024, + site_name: '馒头', state: 'downloading', title: '下载任务标题', upspeed: '1 MiB', + year: '2026', ...overrides, } } @@ -49,10 +52,18 @@ describe('DownloadingCard display and pause state', () => { const { container } = await renderCard() expect(screen.getByText(/测试媒体/)).toBeInTheDocument() - expect(screen.getByText(/S01 E02/)).toBeInTheDocument() + expect(screen.getByText(/2026 · S01 E02/)).toBeInTheDocument() expect(screen.getByText('下载任务标题')).toBeInTheDocument() - expect(screen.getByText(/1 小时/)).toBeInTheDocument() - expect(container.querySelector('.v-card-text .v-progress-linear')).toBeInTheDocument() + expect(screen.getByText('电视剧')).toBeInTheDocument() + expect(screen.getByText('馒头')).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 () => { @@ -62,13 +73,32 @@ describe('DownloadingCard display and pause state', () => { name: '未识别任务', progress: 0, season_episode: 'S03E04', + site_name: undefined, state: 'stopped', }), ) expect(screen.getByText(/未识别任务/)).toBeInTheDocument() expect(screen.getByText(/S03E04/)).toBeInTheDocument() - expect(container.querySelector('.v-card-text .v-progress-linear')).not.toBeInTheDocument() + expect(screen.getByText('未知')).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 () => { diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index cb67cc6f..15a17edb 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -1277,6 +1277,18 @@ export default { title: 'Downloading', noTask: 'No Task', 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: { searchResults: 'Resource Search Results', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 698a2d6d..f8724679 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -1268,6 +1268,18 @@ export default { title: '下载', noTask: '没有任务', noTaskDescription: '正在下载的任务将会显示在这里。', + unknownSite: '未知站点', + statusDownloading: '下载中', + statusPaused: '已暂停', + progress: '下载进度', + size: '大小', + downloadSpeed: '下载', + uploadSpeed: '上传', + remainingTime: '剩余', + calculating: '计算中', + pauseTask: '暂停任务', + resumeTask: '继续任务', + deleteTask: '删除任务', }, resource: { searchResults: '资源搜索结果', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 70185da3..b83a30ab 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -1266,6 +1266,18 @@ export default { title: '下載', noTask: '沒有任務', noTaskDescription: '正在下載的任務將會顯示在這裡。', + unknownSite: '未知站點', + statusDownloading: '下載中', + statusPaused: '已暫停', + progress: '下載進度', + size: '大小', + downloadSpeed: '下載', + uploadSpeed: '上傳', + remainingTime: '剩餘', + calculating: '計算中', + pauseTask: '暫停任務', + resumeTask: '繼續任務', + deleteTask: '刪除任務', }, resource: { searchResults: '資源搜索結果', diff --git a/src/views/reorganize/DownloadingListView.vue b/src/views/reorganize/DownloadingListView.vue index 5cc4d509..d3c9d21e 100644 --- a/src/views/reorganize/DownloadingListView.vue +++ b/src/views/reorganize/DownloadingListView.vue @@ -69,8 +69,8 @@ useKeepAliveRefresh(fetchData, { v-if="filteredDataList.length > 0" :items="filteredDataList" :get-item-key="item => item.hash || item.name" - :min-item-width="320" - :estimated-item-height="230" + :min-item-width="360" + :estimated-item-height="248" >