diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ca90cb17..774f4a7d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -79,7 +79,7 @@ }, "src/api/types.ts": { "@typescript-eslint/no-explicit-any": { - "count": 38 + "count": 39 }, "@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 3f7cdfbf..57cc4990 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 { - // 下载器名称 - downloader?: string - // 来源站点 - site_name?: string // HASH hash?: string // 种子名称 @@ -712,14 +693,8 @@ export interface DownloadingInfo { dlspeed?: string // 上传速度 upspeed?: string - // 下载器分类 - category?: string - // 下载器标签 - tags?: string - // Tracker 地址 - trackers?: string[] // 媒体信息 - media?: DownloadingMediaInfo + media: { [key: string]: any } // 下载用户ID userid?: string // 下载用户名称 diff --git a/src/components/cards/DownloadingCard.vue b/src/components/cards/DownloadingCard.vue index 0b63c493..d88d0341 100644 --- a/src/components/cards/DownloadingCard.vue +++ b/src/components/cards/DownloadingCard.vue @@ -2,26 +2,27 @@ 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' -// 输入参数 +/** 卡片使用的下载任务信息,兼容接口已经返回但公共类型尚未声明的来源站点。 */ +interface DownloadingCardInfo extends DownloadingInfo { + site_name?: string + trackers?: string[] +} + +/** 正在下载任务卡片,负责展示任务状态并提供暂停、继续和删除操作。 */ const props = defineProps({ - info: Object as PropType, + info: Object as PropType, downloaderName: String, }) const { t } = useI18n() -// 是否显示卡片 +// 卡片在删除成功后就地隐藏,等待外层轮询同步任务列表。 const cardState = ref(true) - -// 当前操作,避免轮询刷新期间重复触发控制请求。 const pendingAction = ref<'delete' | 'toggle' | null>(null) - -const media = computed(() => props.info?.media ?? {}) - const imageLoadError = ref(false) +const media = computed(() => props.info?.media ?? {}) watch( () => media.value.image, @@ -30,12 +31,8 @@ watch( }, ) -const imageUrl = computed(() => { - if (!media.value.image || imageLoadError.value) return noImage - return media.value.image -}) +const hasPosterImage = computed(() => Boolean(media.value.image && !imageLoadError.value)) -// 识别信息可能不完整,依次回退到解析名称和原始任务名。 const mediaTitle = computed(() => media.value.title || props.info?.name || props.info?.title || t('common.unknown')) const episodeText = computed(() => { @@ -46,22 +43,23 @@ const episodeText = computed(() => { 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 type = String(media.value.type || '').trim() + if (type === '电影' || type.toLowerCase() === 'movie') return t('mediaType.movie') + if (type === '电视剧' || type.toLowerCase() === 'tv') return t('mediaType.tv') + 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 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 === '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 progress = Number(props.info?.progress ?? 0) if (!Number.isFinite(progress)) return 0 @@ -69,21 +67,37 @@ const progressValue = computed(() => { }) 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) { const value = speed?.trim() || '0 B' return /\/s$/i.test(value) ? value : `${value}/s` } -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') -// 监听props.info?.state的变化 watch( () => props.info?.state, newValue => { @@ -91,7 +105,7 @@ watch( }, ) -// 下载状态控制 +/** 暂停或继续当前任务,并防止请求完成前重复触发。 */ async function toggleDownload() { if (pendingAction.value) return @@ -112,7 +126,7 @@ async function toggleDownload() { } } -// 删除下载任务 +/** 删除当前下载任务,并仅在业务请求成功后隐藏卡片。 */ async function deleteDownload() { if (pendingAction.value) return @@ -141,33 +155,40 @@ async function deleteDownload() { :class="{ 'app-hover-lift-card--hovering': hover.isHovering, 'downloading-card--hovering': hover.isHovering, + 'downloading-card--no-image': !hasPosterImage, }" > -
- +
+ -
-
{{ progressText }}
+
-
+
- + {{ mediaTypeText }} - + {{ sourceSiteText }} + + {{ sizeText }} +
- {{ mediaTitle }} + {{ mediaTitle }} {{ titleMetaText }}
@@ -175,75 +196,69 @@ async function deleteDownload() {
-
+
- {{ isDownloading ? t('downloading.statusDownloading') : t('downloading.statusPaused') }} - {{ progressText }} + + {{ isDownloading ? t('common.download') : t('common.pause') }} + · + {{ remainingTimeText }} + + {{ progressText }}
-
-
- - {{ t('downloading.size') }} - {{ sizeText }} + - -
- - {{ t('downloading.remainingTime') }} - {{ remainingTimeText }} -
-
+ + - {{ isDownloading ? t('downloading.pauseTask') : t('downloading.resumeTask') }} + {{ isDownloading ? t('common.pause') : t('common.download') }} - {{ t('downloading.deleteTask') }} + + {{ t('common.delete') }} -
-
-
+ +
+
@@ -255,27 +270,26 @@ async function deleteDownload() { .downloading-card-hover-area { block-size: 100%; + container-type: inline-size; inline-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; + min-block-size: 13rem; + color: rgb(var(--v-theme-on-surface)); + grid-template-columns: 8.25rem minmax(0, 1fr); } .downloading-card__poster { position: relative; overflow: hidden; - min-block-size: 100%; - background: rgb(var(--v-theme-surface-variant)); + min-block-size: 13rem; + background: rgba(var(--v-theme-on-surface), 0.06); } .downloading-card__image, -.downloading-card__image-placeholder { +.downloading-card__image-loader { block-size: 100%; inline-size: 100%; } @@ -285,40 +299,22 @@ async function deleteDownload() { } .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; - z-index: 1; - background: linear-gradient(180deg, rgba(4, 8, 14, 4%) 35%, rgba(4, 8, 14, 76%) 100%); + background: linear-gradient(90deg, rgba(var(--v-theme-surface), 0) 72%, rgba(var(--v-theme-surface), 0.38)); 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; + padding: 1rem !important; } .downloading-card__chips { @@ -329,7 +325,7 @@ async function deleteDownload() { } .downloading-card__chips :deep(.v-chip) { - max-inline-size: 50%; + max-inline-size: calc(50% - 0.2rem); } .downloading-card__chips :deep(.v-chip__content) { @@ -343,32 +339,37 @@ async function deleteDownload() { } .downloading-card__title { + display: -webkit-box; overflow: hidden; color: rgb(var(--v-theme-on-surface)); font-size: 1rem; font-weight: 700; letter-spacing: 0; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; line-height: 1.35; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } .downloading-card__title-meta { margin-inline-start: 0.35rem; color: rgb(var(--v-theme-primary)); - font-size: 0.8rem; + font-size: 0.76rem; font-weight: 650; + white-space: nowrap; } .downloading-card__torrent-title { display: -webkit-box; overflow: hidden; - margin-block-start: 0.2rem; + margin-block-start: 0.25rem; 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; + line-height: 1.4; + overflow-wrap: anywhere; + white-space: normal; } .downloading-card__progress { @@ -377,120 +378,145 @@ async function deleteDownload() { .downloading-card__progress-label { display: flex; + min-inline-size: 0; align-items: center; 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)); font-size: 0.7rem; - font-weight: 600; + gap: 0.5rem; } -.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 { +.downloading-card__progress-label > span { 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; +.downloading-card__progress-label strong { + flex: 0 0 auto; color: rgb(var(--v-theme-on-surface)); - font-size: 0.72rem; 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; - min-block-size: 2rem; + min-inline-size: 0; align-items: center; justify-content: space-between; margin-block-start: auto; 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; - overflow: hidden; min-inline-size: 0; align-items: center; 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; - gap: 0.3rem; + font-variant-numeric: tabular-nums; + font-weight: 650; + text-overflow: ellipsis; white-space: nowrap; } -.downloading-card__remaining strong { - overflow: hidden; - color: rgb(var(--v-theme-on-surface)); - font-weight: 650; - text-overflow: ellipsis; +.downloading-card__speed--download .v-icon { + color: rgb(var(--v-theme-info)); } -.downloading-card__buttons { +.downloading-card__speed--upload .v-icon { + color: rgb(var(--v-theme-success)); +} + +.downloading-card__actions { display: flex; flex: 0 0 auto; - align-items: center; - gap: 0.2rem; + gap: 0.1rem; } -@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 { - gap: 0.55rem; - padding: 0.8rem; + gap: 0.5rem; + padding: 0.75rem !important; } - .downloading-card__stat + .downloading-card__stat { - padding-inline-start: 0.4rem; + .downloading-card__chips { + gap: 0.3rem; } - .downloading-card__remaining > span { - display: none; + .downloading-card__title { + 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) { diff --git a/src/components/cards/__tests__/DownloadingCard.spec.ts b/src/components/cards/__tests__/DownloadingCard.spec.ts index b0d0c3eb..f7accddd 100644 --- a/src/components/cards/__tests__/DownloadingCard.spec.ts +++ b/src/components/cards/__tests__/DownloadingCard.spec.ts @@ -16,17 +16,14 @@ 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, } } @@ -52,18 +49,10 @@ describe('DownloadingCard display and pause state', () => { const { container } = await renderCard() 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.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() + expect(screen.getByText(/1 小时/)).toBeInTheDocument() + expect(container.querySelector('.v-card-text .v-progress-linear')).toBeInTheDocument() }) 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: '未识别任务', progress: 0, season_episode: 'S03E04', - site_name: undefined, state: 'stopped', }), ) expect(screen.getByText(/未识别任务/)).toBeInTheDocument() expect(screen.getByText(/S03E04/)).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() + expect(container.querySelector('.v-card-text .v-progress-linear')).not.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 15a17edb..cb67cc6f 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -1277,18 +1277,6 @@ 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 f8724679..698a2d6d 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -1268,18 +1268,6 @@ 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 b83a30ab..70185da3 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -1266,18 +1266,6 @@ 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 d3c9d21e..5cc4d509 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="360" - :estimated-item-height="248" + :min-item-width="320" + :estimated-item-height="230" >