From 3f5768dc387513282b3ed1b5cf8191ae2c244458 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 2 Sep 2026 21:35:38 +0800 Subject: [PATCH] fix(subscribe): restore card metadata after completion --- src/components/cards/SubscribeCard.vue | 59 ++++++++++++++---- .../cards/__tests__/SubscribeCard.spec.ts | 61 +++++++++++++++++++ 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/src/components/cards/SubscribeCard.vue b/src/components/cards/SubscribeCard.vue index 619d2986..0ccd7d09 100644 --- a/src/components/cards/SubscribeCard.vue +++ b/src/components/cards/SubscribeCard.vue @@ -14,6 +14,8 @@ import { openSharedDialog } from '@/composables/useSharedDialog' import { getDisplayImageUrl } from '@/utils/imageUtils' import { buildMusicDetailRoute, formatMusicAudioSpecs, formatMusicBitrate } from '@/utils/music' +const COMPLETED_EXECUTION_VISIBLE_MS = 5_000 + const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue')) const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue')) const SubscribeShareDialog = defineAsyncComponent(() => import('../dialog/SubscribeShareDialog.vue')) @@ -68,9 +70,40 @@ const subscribeState = ref(props.media?.state ?? 'P') // 上一次更新时间 const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : '')) +// 成功终态只承担短暂反馈,持久账本仍由后端保留,卡片随后恢复订阅进度。 +const visibleExecutionStatus = ref(null) +let completedExecutionTimer: ReturnType | undefined + +// 清理上一条成功终态的恢复计时器,避免卡片复用后由旧任务覆盖新状态。 +function clearCompletedExecutionTimer() { + if (!completedExecutionTimer) return + clearTimeout(completedExecutionTimer) + completedExecutionTimer = undefined +} + +// 活动与异常状态持续展示;成功完成仅在后端更新时间后的短窗口内展示。 +function syncVisibleExecutionStatus(execution: Subscribe['execution_status']) { + clearCompletedExecutionTimer() + visibleExecutionStatus.value = execution + if (!execution || (execution.state !== 'completed' && execution.phase !== 'completed')) return + + const updatedAt = Date.parse(execution.updated_at) + const elapsed = Number.isFinite(updatedAt) ? Math.max(0, Date.now() - updatedAt) : 0 + const remaining = COMPLETED_EXECUTION_VISIBLE_MS - elapsed + if (remaining <= 0) { + visibleExecutionStatus.value = null + return + } + + completedExecutionTimer = setTimeout(() => { + visibleExecutionStatus.value = null + completedExecutionTimer = undefined + }, remaining) +} + // 将后端稳定业务状态映射为紧凑、可本地化的卡片展示。 const executionStateDisplay = computed(() => { - const execution = props.media?.execution_status + const execution = visibleExecutionStatus.value if (!execution) return null const displays: Record = { queued: { color: 'info', icon: 'mdi-clock-outline' }, @@ -474,6 +507,14 @@ watch( }, ) +watch( + () => props.media?.execution_status, + execution => syncVisibleExecutionStatus(execution), + { immediate: true }, +) + +onBeforeUnmount(() => clearCompletedExecutionTimer()) + // 切换订阅记录时重新尝试加载图片,避免复用卡片组件后沿用旧的失败状态。 watch( () => [props.media?.id, props.media?.backdrop, props.media?.poster], @@ -701,11 +742,7 @@ function handleCardClick() { - + {{ executionStateDisplay.error }} @@ -830,16 +867,14 @@ function handleCardClick() { {{ rightBottomStateDisplay.label }} - + {{ executionStateDisplay.error }} diff --git a/src/components/cards/__tests__/SubscribeCard.spec.ts b/src/components/cards/__tests__/SubscribeCard.spec.ts index 3a70f3fa..821e2624 100644 --- a/src/components/cards/__tests__/SubscribeCard.spec.ts +++ b/src/components/cards/__tests__/SubscribeCard.spec.ts @@ -368,6 +368,67 @@ describe('SubscribeCard display and progress', () => { expect(screen.getByText('等待站点额度')).toBeInTheDocument() expect(screen.getByTitle('站点 9 冷却中')).toBeInTheDocument() }) + + it.each([480, 1024])('briefly shows a fresh completion then restores normal metadata at %ipx', async width => { + setViewport(width) + const { media, rerender, unmount } = await renderCard({ + lack_episode: 4, + state: 'R', + total_episode: 10, + type: '电视剧', + }) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-02T12:00:00+08:00')) + + try { + await rerender({ + media: { + ...media, + execution_status: { + can_cancel: false, + can_retry: false, + phase: 'completed', + requires_reconciliation: false, + state: 'completed', + updated_at: new Date().toISOString(), + }, + }, + }) + + expect(screen.getByText('执行完成')).toBeInTheDocument() + if (width < 600) expect(screen.queryByText('6 / 10')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(5_000) + + expect(screen.queryByText('执行完成')).not.toBeInTheDocument() + expect(screen.getByText('6 / 10')).toBeInTheDocument() + if (width >= 600) expect(screen.getByText(formatDateDifference(media.last_update))).toBeInTheDocument() + } finally { + unmount() + vi.useRealTimers() + } + }) + + it('does not revive an expired completion after the card is reloaded', async () => { + setViewport(480) + await renderCard({ + execution_status: { + can_cancel: false, + can_retry: false, + phase: 'completed', + requires_reconciliation: false, + state: 'completed', + updated_at: new Date(Date.now() - 6_000).toISOString(), + }, + lack_episode: 4, + state: 'R', + total_episode: 10, + type: '电视剧', + }) + + expect(screen.queryByText('执行完成')).not.toBeInTheDocument() + expect(screen.getByText('6 / 10')).toBeInTheDocument() + }) }) describe('SubscribeCard interaction boundaries', () => {