feat: 优化正在下载卡片信息层级

This commit is contained in:
jxxghp
2026-08-09 17:50:19 +08:00
parent 278a7587c1
commit cfd08059a4
2 changed files with 164 additions and 65 deletions

View File

@@ -96,6 +96,8 @@ function formatSpeed(speed?: string) {
const downloadSpeedText = computed(() => formatSpeed(props.info?.dlspeed))
const uploadSpeedText = computed(() => formatSpeed(props.info?.upspeed))
const hasDownloadSpeed = computed(() => Number.parseFloat(props.info?.dlspeed || '') > 0)
const hasUploadSpeed = computed(() => Number.parseFloat(props.info?.upspeed || '') > 0)
// 下载状态跟随轮询数据变化,操作成功时也会立即响应。
const isDownloading = ref(props.info?.state === 'downloading')
@@ -186,31 +188,43 @@ async function deleteDownload() {
</div>
</div>
<div class="downloading-card__chips">
<VChip v-if="mediaTypeText" :prepend-icon="mediaTypeIcon" size="x-small" variant="tonal">
<div class="downloading-card__meta">
<span v-if="mediaTypeText" class="downloading-card__meta-item downloading-card__meta-type">
<VIcon :icon="mediaTypeIcon" size="14" />
{{ mediaTypeText }}
</VChip>
<VChip prepend-icon="mdi-harddisk" size="x-small" variant="tonal">
{{ sizeText }}
</VChip>
<VChip v-if="sourceSiteText" prepend-icon="mdi-web" size="x-small" variant="tonal">
{{ sourceSiteText }}
</VChip>
</span>
<span v-if="mediaTypeText" aria-hidden="true" class="downloading-card__meta-separator">·</span>
<span class="downloading-card__meta-item downloading-card__meta-size">{{ sizeText }}</span>
<template v-if="sourceSiteText">
<span aria-hidden="true" class="downloading-card__meta-separator">·</span>
<span class="downloading-card__meta-item downloading-card__meta-source" :title="sourceSiteText">
{{ sourceSiteText }}
</span>
</template>
</div>
<div v-if="progressValue > 0" class="downloading-card__progress">
<div
v-if="progressValue > 0"
class="downloading-card__progress"
:class="isDownloading ? 'downloading-card__progress--active' : 'downloading-card__progress--paused'"
>
<div class="downloading-card__progress-label">
<span>
{{ isDownloading ? t('common.download') : t('common.pause') }}
<span class="downloading-card__progress-separator">·</span>
{{ remainingTimeText }}
</span>
<div class="downloading-card__progress-copy">
<span class="downloading-card__progress-state">
<VIcon :icon="isDownloading ? 'mdi-download' : 'mdi-pause'" size="14" />
{{ isDownloading ? t('common.download') : t('common.pause') }}
</span>
<span class="downloading-card__progress-remaining">
<span aria-hidden="true" class="downloading-card__progress-separator">·</span>
{{ remainingTimeText }}
</span>
</div>
<strong>{{ progressText }}</strong>
</div>
<VProgressLinear
:aria-label="t('common.download')"
:aria-label="isDownloading ? t('common.download') : t('common.pause')"
:model-value="progressValue"
:color="isDownloading ? 'success' : 'warning'"
:color="isDownloading ? 'info' : 'warning'"
bg-color="surface-variant"
height="6"
rounded
@@ -219,11 +233,17 @@ async function deleteDownload() {
<div class="downloading-card__footer">
<div class="downloading-card__speeds">
<div class="downloading-card__speed downloading-card__speed--download">
<div
class="downloading-card__speed downloading-card__speed--download"
:class="{ 'downloading-card__speed--idle': !hasDownloadSpeed }"
>
<VIcon icon="mdi-arrow-down" size="16" />
<strong :title="downloadSpeedText">{{ downloadSpeedText }}</strong>
</div>
<div class="downloading-card__speed downloading-card__speed--upload">
<div
class="downloading-card__speed downloading-card__speed--upload"
:class="{ 'downloading-card__speed--idle': !hasUploadSpeed }"
>
<VIcon icon="mdi-arrow-up" size="16" />
<strong :title="uploadSpeedText">{{ uploadSpeedText }}</strong>
</div>
@@ -235,7 +255,7 @@ async function deleteDownload() {
:disabled="pendingAction === 'delete'"
icon
:loading="pendingAction === 'toggle'"
color="primary"
:color="isDownloading ? 'info' : 'warning'"
size="small"
variant="tonal"
@click="toggleDownload"
@@ -247,9 +267,10 @@ async function deleteDownload() {
</VBtn>
<VBtn
:aria-label="t('common.delete')"
class="downloading-card__delete-action"
color="on-surface"
:disabled="pendingAction === 'toggle'"
:loading="pendingAction === 'delete'"
color="error"
icon
size="small"
variant="text"
@@ -312,28 +333,43 @@ async function deleteDownload() {
display: flex;
min-inline-size: 0;
flex-direction: column;
gap: 0.55rem;
padding: 0.875rem !important;
gap: 0.7rem;
padding: 1rem !important;
}
.downloading-card__chips {
.downloading-card__meta {
display: flex;
min-inline-size: 0;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.72rem;
gap: 0.35rem;
line-height: 1.35;
white-space: nowrap;
}
.downloading-card__chips :deep(.v-chip) {
flex: 0 1 auto;
.downloading-card__meta-item {
display: inline-flex;
min-inline-size: 0;
max-inline-size: 100%;
align-items: center;
gap: 0.2rem;
}
.downloading-card__chips :deep(.v-chip__content) {
.downloading-card__meta-type,
.downloading-card__meta-size,
.downloading-card__meta-separator {
flex: 0 0 auto;
}
.downloading-card__meta-type {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-weight: 600;
}
.downloading-card__meta-source {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.downloading-card__heading {
@@ -362,20 +398,24 @@ async function deleteDownload() {
}
.downloading-card__torrent-title {
display: -webkit-box;
display: block;
overflow: hidden;
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.4;
overflow-wrap: anywhere;
white-space: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.downloading-card__progress {
min-inline-size: 0;
--downloading-card-status-color: rgb(var(--v-theme-info));
}
.downloading-card__progress--paused {
--downloading-card-status-color: rgb(var(--v-theme-warning));
}
.downloading-card__progress-label {
@@ -383,13 +423,29 @@ async function deleteDownload() {
min-inline-size: 0;
align-items: center;
justify-content: space-between;
margin-block-end: 0.4rem;
margin-block-end: 0.35rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.7rem;
font-size: 0.72rem;
gap: 0.5rem;
}
.downloading-card__progress-label > span {
.downloading-card__progress-copy {
display: flex;
min-inline-size: 0;
align-items: center;
gap: 0.15rem;
}
.downloading-card__progress-state {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
color: var(--downloading-card-status-color);
font-weight: 650;
gap: 0.2rem;
}
.downloading-card__progress-remaining {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -397,9 +453,10 @@ async function deleteDownload() {
.downloading-card__progress-label strong {
flex: 0 0 auto;
color: rgb(var(--v-theme-on-surface));
color: var(--downloading-card-status-color);
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
font-size: 0.82rem;
font-weight: 700;
}
.downloading-card__progress-separator {
@@ -435,10 +492,10 @@ async function deleteDownload() {
.downloading-card__speed strong {
overflow: hidden;
color: rgb(var(--v-theme-on-surface));
font-size: 0.7rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
font-weight: 650;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -447,16 +504,35 @@ async function deleteDownload() {
color: rgb(var(--v-theme-info));
}
.downloading-card__speed--download strong {
color: rgb(var(--v-theme-on-surface));
font-weight: 700;
}
.downloading-card__speed--upload .v-icon {
color: rgb(var(--v-theme-success));
}
.downloading-card__speed--idle {
opacity: 0.52;
}
.downloading-card__actions {
display: flex;
flex: 0 0 auto;
gap: 0.1rem;
}
.downloading-card__delete-action {
opacity: var(--v-medium-emphasis-opacity);
}
.downloading-card__delete-action:hover,
.downloading-card__delete-action:focus-visible {
color: rgb(var(--v-theme-error)) !important;
opacity: 1;
}
@container (width <= 25rem) {
.downloading-card {
min-block-size: 11rem;
@@ -468,12 +544,8 @@ async function deleteDownload() {
}
.downloading-card__body {
gap: 0.45rem;
padding: 0.65rem !important;
}
.downloading-card__chips {
gap: 0.3rem;
gap: 0.55rem;
padding: 0.75rem !important;
}
.downloading-card__title {
@@ -481,7 +553,12 @@ async function deleteDownload() {
}
.downloading-card__torrent-title {
font-size: 0.7rem;
font-size: 0.69rem;
}
.downloading-card__meta,
.downloading-card__progress-label {
font-size: 0.69rem;
}
.downloading-card__speeds {
@@ -495,7 +572,7 @@ async function deleteDownload() {
@container (width <= 21rem) {
.downloading-card__body {
padding-inline: 0.65rem !important;
padding-inline: 0.75rem !important;
}
.downloading-card__actions :deep(.v-btn) {

View File

@@ -63,6 +63,7 @@ describe('DownloadingCard display and pause state', () => {
expect(screen.getByText('下载任务标题')).toBeInTheDocument()
expect(screen.getByText(/1 小时/)).toBeInTheDocument()
expect(container.querySelector('.v-card-text .v-progress-linear')).toBeInTheDocument()
expect(container.querySelector('.downloading-card__progress')).toHaveClass('downloading-card__progress--active')
})
it('falls back to the task name and season string when media recognition is incomplete', async () => {
@@ -93,12 +94,16 @@ describe('DownloadingCard display and pause state', () => {
}),
)
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电影')
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent('电影')
expect(container.querySelector('.downloading-card__progress')).toHaveTextContent('100%')
expect(container.querySelector('.downloading-card__progress')).toHaveTextContent('--')
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('3 MiB/s')
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('0 B/s')
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('0.00 B')
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent('0.00 B')
expect(container.querySelector('.downloading-card__speed--download')).not.toHaveClass(
'downloading-card__speed--idle',
)
expect(container.querySelector('.downloading-card__speed--upload')).toHaveClass('downloading-card__speed--idle')
for (const [type, label] of [
['电影', '电影'],
@@ -110,7 +115,7 @@ describe('DownloadingCard display and pause state', () => {
downloaderName: 'qb-main',
info: downloading({ media: { title: `${type}标题`, type }, progress: -1 }),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent(label)
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent(label)
expect(container.querySelector('.downloading-card__progress')).not.toBeInTheDocument()
}
@@ -124,38 +129,55 @@ describe('DownloadingCard display and pause state', () => {
upspeed: '1 MiB/s',
}),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电影')
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent('电影')
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('0 B/s')
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('1 MiB/s')
expect(container.querySelector('.downloading-card__speed--download')).toHaveClass('downloading-card__speed--idle')
expect(container.querySelector('.downloading-card__speed--upload')).not.toHaveClass('downloading-card__speed--idle')
await rerender({
downloaderName: 'qb-main',
info: downloading({ media: { season: 'S02' }, name: '', season_episode: undefined, title: '任务标题回退' }),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电视剧')
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent('电视剧')
expect(container.querySelector('.downloading-card__title')).toHaveTextContent('任务标题回退')
await rerender({ downloaderName: '', info: undefined })
expect(container.querySelector('.downloading-card__title')).toHaveTextContent('未知')
expect(container.querySelectorAll('.downloading-card__chips .v-chip')).toHaveLength(1)
expect(container.querySelectorAll('.downloading-card__meta-item')).toHaveLength(1)
})
it('uses download state as the progress emphasis', async () => {
const { container, rerender } = await renderCard()
expect(container.querySelector('.downloading-card__progress')).toHaveClass('downloading-card__progress--active')
expect(screen.getByRole('progressbar', { name: '下载' })).toBeInTheDocument()
await rerender({
downloaderName: 'qb-main',
info: downloading({ state: 'stopped' }),
})
expect(container.querySelector('.downloading-card__progress')).toHaveClass('downloading-card__progress--paused')
expect(screen.getByRole('progressbar', { name: '暂停' })).toBeInTheDocument()
})
it('keeps torrent size visible while resolving explicit site names and tracker hostnames', async () => {
const { container, rerender } = await renderCard(downloading({ site_name: ' M-Team ' }))
const heading = container.querySelector('.downloading-card__heading')!
const chipContainer = container.querySelector('.downloading-card__chips')!
const chips = [...chipContainer.querySelectorAll('.v-chip')]
const metaContainer = container.querySelector('.downloading-card__meta')!
const metaItems = [...metaContainer.querySelectorAll('.downloading-card__meta-item')]
expect(heading.nextElementSibling).toBe(chipContainer)
expect(chips.map(chip => chip.textContent?.trim())).toEqual(['电视剧', '1.00 KB', 'M-Team'])
expect(chips.every(chip => !chip.classList.contains('text-primary'))).toBe(true)
expect(heading.nextElementSibling).toBe(metaContainer)
expect(metaItems.map(item => item.textContent?.trim())).toEqual(['电视剧', '1.00 KB', 'M-Team'])
expect(metaContainer.querySelector('.v-chip')).not.toBeInTheDocument()
await rerender({
downloaderName: 'qb-main',
info: downloading({ media: { ...downloading().media, site_name: '媒体站点' }, site_name: undefined }),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('媒体站点')
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent('媒体站点')
await rerender({
downloaderName: 'qb-main',
@@ -165,7 +187,7 @@ describe('DownloadingCard display and pause state', () => {
trackers: ['', 'not-a-url', 'https://www.tracker.example.com/announce?passkey=secret'],
}),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('tracker.example.com')
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent('tracker.example.com')
expect(container).not.toHaveTextContent('passkey')
expect(container).not.toHaveTextContent('secret')
})