mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 18:26:45 +08:00
fix(subscribe): restore card metadata after completion
This commit is contained in:
@@ -14,6 +14,8 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
|
|||||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||||
import { buildMusicDetailRoute, formatMusicAudioSpecs, formatMusicBitrate } from '@/utils/music'
|
import { buildMusicDetailRoute, formatMusicAudioSpecs, formatMusicBitrate } from '@/utils/music'
|
||||||
|
|
||||||
|
const COMPLETED_EXECUTION_VISIBLE_MS = 5_000
|
||||||
|
|
||||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||||
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
|
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
|
||||||
const SubscribeShareDialog = defineAsyncComponent(() => import('../dialog/SubscribeShareDialog.vue'))
|
const SubscribeShareDialog = defineAsyncComponent(() => import('../dialog/SubscribeShareDialog.vue'))
|
||||||
@@ -68,9 +70,40 @@ const subscribeState = ref<string>(props.media?.state ?? 'P')
|
|||||||
// 上一次更新时间
|
// 上一次更新时间
|
||||||
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
|
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
|
||||||
|
|
||||||
|
// 成功终态只承担短暂反馈,持久账本仍由后端保留,卡片随后恢复订阅进度。
|
||||||
|
const visibleExecutionStatus = ref<Subscribe['execution_status'] | null>(null)
|
||||||
|
let completedExecutionTimer: ReturnType<typeof setTimeout> | 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 executionStateDisplay = computed(() => {
|
||||||
const execution = props.media?.execution_status
|
const execution = visibleExecutionStatus.value
|
||||||
if (!execution) return null
|
if (!execution) return null
|
||||||
const displays: Record<string, { color: string; icon: string }> = {
|
const displays: Record<string, { color: string; icon: string }> = {
|
||||||
queued: { color: 'info', icon: 'mdi-clock-outline' },
|
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(
|
watch(
|
||||||
() => [props.media?.id, props.media?.backdrop, props.media?.poster],
|
() => [props.media?.id, props.media?.backdrop, props.media?.poster],
|
||||||
@@ -701,11 +742,7 @@ function handleCardClick() {
|
|||||||
<span v-if="compactStateText" class="subscribe-card-mobile-progress-text">
|
<span v-if="compactStateText" class="subscribe-card-mobile-progress-text">
|
||||||
{{ compactStateText }}
|
{{ compactStateText }}
|
||||||
</span>
|
</span>
|
||||||
<VTooltip
|
<VTooltip v-if="executionStateDisplay?.error" activator="parent" location="top">
|
||||||
v-if="executionStateDisplay?.error"
|
|
||||||
activator="parent"
|
|
||||||
location="top"
|
|
||||||
>
|
|
||||||
{{ executionStateDisplay.error }}
|
{{ executionStateDisplay.error }}
|
||||||
</VTooltip>
|
</VTooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -830,16 +867,14 @@ function handleCardClick() {
|
|||||||
<VCardText
|
<VCardText
|
||||||
v-if="rightBottomStateDisplay"
|
v-if="rightBottomStateDisplay"
|
||||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||||
:style="executionStateDisplay ? { color: `rgb(var(--v-theme-${executionStateDisplay.color}))` } : undefined"
|
:style="
|
||||||
|
executionStateDisplay ? { color: `rgb(var(--v-theme-${executionStateDisplay.color}))` } : undefined
|
||||||
|
"
|
||||||
:title="executionStateDisplay?.error || rightBottomStateDisplay.label"
|
:title="executionStateDisplay?.error || rightBottomStateDisplay.label"
|
||||||
>
|
>
|
||||||
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
||||||
{{ rightBottomStateDisplay.label }}
|
{{ rightBottomStateDisplay.label }}
|
||||||
<VTooltip
|
<VTooltip v-if="executionStateDisplay?.error" activator="parent" location="top">
|
||||||
v-if="executionStateDisplay?.error"
|
|
||||||
activator="parent"
|
|
||||||
location="top"
|
|
||||||
>
|
|
||||||
{{ executionStateDisplay.error }}
|
{{ executionStateDisplay.error }}
|
||||||
</VTooltip>
|
</VTooltip>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|||||||
@@ -368,6 +368,67 @@ describe('SubscribeCard display and progress', () => {
|
|||||||
expect(screen.getByText('等待站点额度')).toBeInTheDocument()
|
expect(screen.getByText('等待站点额度')).toBeInTheDocument()
|
||||||
expect(screen.getByTitle('站点 9 冷却中')).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', () => {
|
describe('SubscribeCard interaction boundaries', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user