Files
MoviePilot-Frontend/src/components/cards/SubscribeCard.vue

1102 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import { formatDateDifference } from '@/@core/utils/formatters'
import { formatSeasonLabel } from '@/@core/utils/season'
import api from '@/api'
import type { Subscribe } from '@/api/types'
import router from '@/router'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { buildMusicDetailRoute } from '@/utils/music'
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
const SubscribeShareDialog = defineAsyncComponent(() => import('../dialog/SubscribeShareDialog.vue'))
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
media: Object as PropType<Subscribe>,
batchMode: {
type: Boolean,
default: false,
},
selected: {
type: Boolean,
default: false,
},
sortable: {
type: Boolean,
default: false,
},
})
// 从 provide 中获取全局设置
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save', 'select'])
// 确认框
const createConfirm = useConfirm()
// 提示框
const $toast = useToast()
// 图片是否加载完成
const imageLoaded = ref(false)
// 背景图或海报加载失败时使用统一占位图,避免订阅卡片留下空白图片区。
const backdropLoadError = ref(false)
const posterLoadError = ref(false)
// 当前的订阅状态
const subscribeState = ref<string>(props.media?.state ?? 'P')
// 上一次更新时间
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
// 判断后端数字/布尔开关是否启用
function isEnabledFlag(value: any) {
return value === true || value === 1 || value === '1'
}
// 订阅列表接口通常返回中文媒体类型,插件或缓存数据可能只保留剧集字段
function isTvSubscribe(media?: Subscribe) {
return media?.type === '电视剧' || media?.type === 'tv' || !!media?.season || !!media?.total_episode
}
// 已下载集数total_episode - lack_episode
const downloadedEpisode = computed(() => {
const total = props.media?.total_episode || 0
if (!total) return 0
return Math.min(Math.max(total - (props.media?.lack_episode || 0), 0), total)
})
// 是否开启洗版,供电影和电视剧共用洗版标识与配色。
const hasBestVersion = computed(() => isEnabledFlag(props.media?.best_version))
// 是否为电视剧洗版订阅,仅影响分集进度条与 tooltip 的展示分支。
const isBestVersion = computed(() => hasBestVersion.value && isTvSubscribe(props.media))
const rightBottomStateDisplay = computed(() => {
if (subscribeState.value === 'S') {
return { icon: 'mdi-pause-circle', label: t('subscribe.cardStatePaused') }
}
if (subscribeState.value === 'P') {
return { icon: 'mdi-clock', label: t('subscribe.cardStatePending') }
}
return null
})
// 移动端紧凑卡片的状态展示,颜色统一映射到 Vuetify 全局主题 token。
const compactStateDisplay = computed(() => {
if (subscribeState.value === 'S') {
return { color: 'secondary', icon: 'mdi-pause-circle-outline', label: t('subscribe.cardStatePaused') }
}
if (subscribeState.value === 'P') {
return { color: 'info', icon: 'mdi-timer-sand', label: t('subscribe.cardStatePending') }
}
if (hasBestVersion.value) {
return { color: 'success', icon: 'mdi-shimmer', label: t('subscribe.subscribing') }
}
return { color: 'primary', icon: 'mdi-rss', label: t('subscribe.subscribing') }
})
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
const bestVersionBadge = computed(() => {
if (!hasBestVersion.value) return null
return {
icon: 'mdi-shimmer',
full: isEnabledFlag(props.media?.best_version_full),
}
})
// 已洗版集数:取后端派生字段 completed_episode
const completedEpisode = computed(() => {
const total = props.media?.total_episode || 0
return Math.min(Math.max(props.media?.completed_episode ?? 0, 0), total)
})
// 卡片主文案:已下载集数 / 总集数
const subscribeProgressText = computed(() => {
const total = props.media?.total_episode || 0
if (!total) return ''
return `${downloadedEpisode.value} / ${total}`
})
// 专辑订阅按整包完成,不伪造分集进度;这里只展示整专规模,提醒用户该目标包含多首曲目。
const musicAlbumTrackText = computed(() => {
if (props.media?.type !== '音乐' || props.media?.music_type !== 'album' || !props.media?.total_tracks) return ''
return `${t('music.entityAlbum')} · ${t('music.trackCount', { count: props.media.total_tracks })}`
})
// 订阅卡片 hover 文案:
// - 普通订阅:「已下载 X · 共 Y 集」
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
const subscribeProgressTooltip = computed(() => {
const total = props.media?.total_episode || 0
if (!total) return ''
if (isBestVersion.value) {
return t('subscribe.bestVersionEpisodeProgressTooltip', {
completed: completedEpisode.value,
downloaded: downloadedEpisode.value,
total,
})
}
return t('subscribe.subscribeProgressTooltip', { downloaded: downloadedEpisode.value, total })
})
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 背景图加载失败后直接切换占位图,避免同一失效地址在 poster fallback 中重复请求。
function backdropErrorHandler() {
backdropLoadError.value = true
imageLoaded.value = true
}
// 海报加载失败后使用占位图,保留卡片布局和可点击区域。
function posterErrorHandler() {
posterLoadError.value = true
}
// 进度条 model 段百分比:洗版订阅表示"已洗版"占比(亮段),普通订阅表示"已下载"占比
function getPercentage() {
const total = props.media?.total_episode || 0
if (!total) return 0
const value = isBestVersion.value ? completedEpisode.value : downloadedEpisode.value
return Math.round((value / total) * 100)
}
// 洗版进度条的 buffer 段百分比:表示"已下载"占比,仅在洗版场景被模板调用
function getBufferPercentage() {
const total = props.media?.total_episode || 0
if (!isBestVersion.value || !total) return 0
return Math.round((downloadedEpisode.value / total) * 100)
}
// 删除订阅
async function removeSubscribe() {
try {
const result: { [key: string]: any } = await api.delete(`subscribe/${props.media?.id}`)
if (result.success) {
// 通知父组件刷新
emit('remove')
}
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
// 搜索订阅
async function searchSubscribe() {
try {
const result: { [key: string]: any } = await api.get(`subscribe/search/${props.media?.id}`)
// 提示
if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功!`)
else $toast.error(t('subscribe.requestFailed'))
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
// 切换订阅状态
async function toggleSubscribeStatus(state: 'R' | 'S') {
try {
// 根据传入的 state 判断对应的操作文字
const action = state === 'S' ? t('common.pause') : t('common.enable')
// 弹出确认框
const isConfirmed = await createConfirm({
title: t('common.confirmAction', { action }),
content: t('subscribe.confirmToggle', { action, name: props.media?.name }),
})
if (!isConfirmed) return
// 调用 API 更新订阅状态
const result: { [key: string]: any } = await api.put(`subscribe/status/${props.media?.id}?state=${state}`)
// 提示
if (result.success) {
$toast.success(t('subscribe.toggleSuccess', { name: props.media?.name, action }))
subscribeState.value = state
emit('save')
} else {
$toast.error(t('subscribe.toggleFailed', { action, message: result.message }))
}
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
// 重置订阅
async function resetSubscribe() {
// 确认
try {
const isConfirmed = await createConfirm({
title: t('common.confirm'),
content: t('subscribe.resetConfirm', { name: props.media?.name }),
})
if (!isConfirmed) return
// 重置
const result: { [key: string]: any } = await api.get(`subscribe/reset/${props.media?.id}`)
// 提示
if (result.success) {
$toast.success(t('subscribe.resetSuccess', { name: props.media?.name }))
subscribeState.value = 'R'
emit('save')
} else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message }))
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
// 分享订阅
async function shareSubscribe() {
if (!props.media) return
openSharedDialog(SubscribeShareDialog, { sub: props.media }, {}, { closeOn: ['close'] })
}
// 编辑订阅响应
async function editSubscribeDialog() {
openSharedDialog(
SubscribeEditDialog,
{ subid: props.media?.id },
{
remove: onSubscribeEditRemove,
save: onSubscribeEditSave,
},
{ closeOn: ['close', 'save', 'remove'] },
)
}
// 获得mediaid
function getMediaId() {
if (props.media?.media_source && props.media?.media_id) {
const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source
return `${prefix}:${props.media.media_id}`
}
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
else if (props.media?.anilistid) return `anilist:${props.media?.anilistid}`
else return props.media?.mediaid
}
// 查看媒体详情
async function viewMediaDetail() {
if (props.media?.type === '音乐') {
router.push(buildMusicDetailRoute(props.media))
return
}
router.push({
path: '/media',
query: {
mediaid: getMediaId(),
title: props.media?.name,
year: props.media?.year,
type: props.media?.type,
},
})
}
// 查看文件详情
async function viewSubscribeFiles() {
openSharedDialog(SubscribeFilesDialog, { subid: props.media?.id }, {}, { closeOn: ['close'] })
}
// 弹出菜单
const dropdownItems = computed(() => [
{
title: t('common.edit'),
value: 1,
props: {
prependIcon: 'mdi-file-edit-outline',
click: editSubscribeDialog,
},
},
{
title: t('common.search'),
value: 2,
props: {
prependIcon: 'mdi-magnify',
click: searchSubscribe,
},
},
{
title: subscribeState.value === 'S' ? t('common.enable') : t('common.pause'),
value: 5,
props: {
prependIcon: subscribeState.value === 'S' ? 'mdi-play' : 'mdi-pause',
click: () => toggleSubscribeStatus(subscribeState.value === 'S' ? 'R' : 'S'),
color: subscribeState.value === 'S' ? 'success' : 'info',
},
},
{
title: t('common.reset'),
value: 6,
props: {
prependIcon: 'mdi-restore-alert',
click: resetSubscribe,
color: 'warning',
},
},
{
title: t('common.share'),
value: 7,
props: {
prependIcon: 'mdi-share',
click: shareSubscribe,
color: 'success',
},
show: props.media?.type === '电视剧',
},
{
title: t('subscribe.mediaDetail'),
value: 3,
props: {
prependIcon: 'mdi-information-outline',
click: viewMediaDetail,
},
},
{
title: t('subscribe.fileStatistics'),
value: 4,
props: {
prependIcon: 'mdi-file-document-outline',
click: viewSubscribeFiles,
},
show: props.media?.type !== '音乐',
},
{
title: t('common.unsubscribe'),
value: 8,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
click: removeSubscribe,
},
},
])
// 监听插件窗口状态变化
watch(
() => props.media?.page_open,
(newOpenState, _) => {
if (newOpenState) editSubscribeDialog()
},
{ immediate: true },
)
// 监听订阅状态
watch(
() => props.media?.state,
newState => {
subscribeState.value = newState ?? 'P'
},
)
// 切换订阅记录时重新尝试加载图片,避免复用卡片组件后沿用旧的失败状态。
watch(
() => [props.media?.id, props.media?.backdrop, props.media?.poster],
() => {
imageLoaded.value = false
backdropLoadError.value = false
posterLoadError.value = false
},
)
// 媒体占位图标:电影/电视剧/音乐各自使用对应图标,缺失封面时统一渲染图标 + 底色占位
const placeholderIcon = computed(() => {
switch (props.media?.type) {
case '音乐':
return 'mdi-album'
case '电视剧':
return 'mdi-television-classic'
case '电影':
default:
return 'mdi-movie-open-outline'
}
})
// 计算backdrop图片地址
const backdropUrl = computed(() => {
if (backdropLoadError.value) return ''
const url = props.media?.backdrop || props.media?.poster
if (!url) return ''
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
})
// 计算海报图片地址
const posterUrl = computed(() => {
if (posterLoadError.value) return ''
const url = props.media?.poster || props.media?.backdrop
if (!url) return ''
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
})
// 缺失封面时展示媒体占位背景(图标 + 底色),对齐音乐媒体卡片
const showPlaceholder = computed(() => !backdropUrl.value)
// 占位背景出现时同步标记图片已加载,让卡片正文与徽标正常渲染
watch(
showPlaceholder,
show => {
if (show) imageLoaded.value = true
},
{ immediate: true },
)
// 订阅编辑保存
function onSubscribeEditSave() {
emit('save')
}
// 订阅编辑取消
function onSubscribeEditRemove() {
emit('remove')
}
// 处理卡片点击事件
function handleCardClick() {
if (props.sortable) {
return
}
if (props.batchMode) {
// 批量模式下触发选择事件
emit('select')
} else {
// 非批量模式下打开编辑弹窗
editSubscribeDialog()
}
}
</script>
<template>
<div>
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="subscribe-card-hover-area w-full h-full">
<div
class="subscribe-card-shell app-hover-lift-card w-full h-full relative"
:class="{
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
'subscribe-card-shell--selected': props.batchMode && props.selected,
}"
>
<VCard
:key="props.media?.id"
class="subscribe-card flex flex-col h-full overflow-hidden"
:class="{
'subscribe-card-paused': subscribeState === 'S',
'subscribe-card-pending-tint': subscribeState === 'P',
'subscribe-card-best-version-tint': display.xs.value && hasBestVersion && subscribeState === 'R',
'cursor-move': props.sortable,
}"
min-height="150"
@click="handleCardClick"
:ripple="display.smAndUp.value && !props.batchMode && !props.sortable"
>
<div
v-if="bestVersionBadge && imageLoaded && display.smAndUp.value"
class="best-version-badge"
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
>
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
</div>
<div v-if="!props.sortable && display.smAndUp.value" class="me-n3 absolute top-1 right-4">
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image v-if="display.smAndUp.value">
<div
v-if="showPlaceholder"
class="subscribe-card-placeholder subscribe-card-placeholder--cover d-flex align-center justify-center relative"
>
<VIcon :icon="placeholderIcon" size="64" color="medium-emphasis" />
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</div>
<VImg
v-else
:src="backdropUrl || posterUrl"
aspect-ratio="3/2"
cover
@load="imageLoadHandler"
@error="backdropErrorHandler"
position="top"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</template>
</VImg>
</template>
<template v-if="display.xs.value">
<div class="subscribe-card-mobile-media">
<div
v-if="showPlaceholder"
class="subscribe-card-placeholder d-flex align-center justify-center relative"
>
<VIcon :icon="placeholderIcon" size="64" color="medium-emphasis" />
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</div>
<VImg
v-else
:src="backdropUrl || posterUrl"
:aspect-ratio="16 / 9"
cover
position="top"
@load="imageLoadHandler"
@error="backdropErrorHandler"
>
<template #placeholder>
<VSkeletonLoader class="h-full w-full" />
</template>
</VImg>
<div class="subscribe-card-mobile-image-scrim subscribe-card-background"></div>
<div v-if="props.media?.username || lastUpdateText" class="subscribe-card-mobile-image-meta">
<div
v-if="props.media?.username"
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__user"
:title="props.media?.username"
>
<VIcon icon="mdi-account" size="14" />
<span>{{ props.media?.username }}</span>
</div>
<div
v-if="lastUpdateText"
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__updated"
>
<VIcon icon="mdi-download" size="14" />
<span>{{ lastUpdateText }}</span>
</div>
</div>
<div class="subscribe-card-mobile-title">
<div class="subscribe-card-mobile-title-text">
<span>{{ props.media?.name }}</span>
<span
v-if="formatSeasonLabel(props.media?.season, t('media.specials'))"
class="subscribe-card-mobile-season"
>
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
</span>
</div>
</div>
</div>
<div class="subscribe-card-mobile-body">
<div class="subscribe-card-mobile-footer">
<div class="subscribe-card-mobile-meta">
<div
class="subscribe-card-mobile-state"
:style="{ color: `rgb(var(--v-theme-${compactStateDisplay.color}))` }"
:title="compactStateDisplay.label"
:aria-label="compactStateDisplay.label"
>
<VIcon
:icon="compactStateDisplay.icon"
:data-subscribe-state-icon="compactStateDisplay.icon"
size="16"
/>
<span
v-if="subscribeProgressText || musicAlbumTrackText"
class="subscribe-card-mobile-progress-text"
>
{{ subscribeProgressText || musicAlbumTrackText }}
</span>
</div>
<IconBtn v-if="!props.sortable" class="subscribe-card-mobile-menu" size="small" @click.stop>
<VIcon icon="mdi-dots-horizontal" size="18" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem
v-if="item.show !== false"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<div v-if="props.media?.total_episode" class="subscribe-card-mobile-progress">
<VProgressLinear
:model-value="getPercentage()"
:bg-color="compactStateDisplay.color"
:color="compactStateDisplay.color"
bg-opacity="0.18"
height="3"
rounded
/>
</div>
</div>
</div>
</template>
<div v-else>
<VCardText class="flex flex-1 items-center pt-3 pb-9">
<div
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
v-if="imageLoaded && posterUrl"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
>
<VImg :src="posterUrl" aspect-ratio="2/3" cover @error="posterErrorHandler">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
<div
class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ..."
>
{{ props.media?.name }}
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
</div>
</div>
</VCardText>
<VCardText
class="absolute inset-x-0 bottom-2 z-10 flex min-w-0 justify-space-between align-center flex-wrap px-3"
>
<div class="flex min-w-0 max-w-full align-center">
<VIcon
v-if="props.media?.total_episode && props.sortable"
icon="mdi-progress-download"
size="small"
color="white"
class="me-1"
/>
<IconBtn
v-else-if="props.media?.total_episode"
size="small"
v-bind="props"
icon="mdi-progress-download"
color="white"
/>
<!-- 守卫改用 total_episode电视剧订阅可能不带 season 字段旧数据或自定义来源仍应展示集数进度 -->
<div v-if="props.media?.total_episode" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
{{ subscribeProgressText }}
<VTooltip v-if="subscribeProgressTooltip" activator="parent" location="top">
{{ subscribeProgressTooltip }}
</VTooltip>
</div>
<div
v-else-if="musicAlbumTrackText"
class="flex flex-shrink-0 align-center text-subtitle-2 me-2 text-white"
>
<VIcon icon="mdi-album" size="small" class="me-1" />
{{ musicAlbumTrackText }}
</div>
<VIcon
v-if="props.media?.username && props.sortable"
icon="mdi-account"
size="small"
color="white"
class="flex-shrink-0 me-1"
/>
<IconBtn
v-else-if="props.media?.username"
icon="mdi-account"
size="small"
color="white"
class="flex-shrink-0"
/>
<!-- 用户名过长时限制在卡片宽度内并用省略号展示剩余内容 -->
<span
v-if="props.media?.username"
class="min-w-0 truncate text-subtitle-2 text-white"
:title="props.media?.username"
>
{{ props.media?.username }}
</span>
</div>
</VCardText>
<!-- 右下角元数据暂停 / 待定时替换"x 天前"为状态文案 -->
<VCardText
v-if="rightBottomStateDisplay"
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
>
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
{{ rightBottomStateDisplay.label }}
</VCardText>
<VCardText
v-else-if="lastUpdateText"
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
>
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<div class="w-full absolute bottom-0">
<!--
分集洗版模式底色保持深绿buffer 段显示"已下载未洗版"为浅绿model 段显示"已洗版完成"为亮绿
形成两段语义其余订阅维持原有单段进度条
-->
<VProgressLinear
v-if="isBestVersion && getBufferPercentage() > 0"
:model-value="getPercentage()"
:buffer-value="getBufferPercentage()"
bg-color="success"
bg-opacity="0.25"
color="success"
buffer-color="success"
buffer-opacity="0.55"
/>
<VProgressLinear
v-else-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
/>
</div>
</div>
</VCard>
</div>
</div>
</template>
</VHover>
</div>
</template>
<style lang="scss" scoped>
.subscribe-card-hover-area {
inline-size: 100%;
}
/**
* 订阅卡片外壳:选中态虚线框复用同一圆角,避免 outline 在圆角卡片外形成直角。
*/
.subscribe-card-shell {
border-radius: var(--app-surface-radius);
}
.subscribe-card {
border: var(--app-card-light-border);
}
.subscribe-card-mobile-media {
position: relative;
overflow: hidden;
aspect-ratio: 16 / 9;
flex-shrink: 0;
inline-size: 100%;
}
.subscribe-card-mobile-media .v-img {
block-size: 100%;
}
.subscribe-card-mobile-image-scrim {
position: absolute;
z-index: 1;
inset: 0;
pointer-events: none;
}
.subscribe-card-mobile-image-meta {
position: absolute;
z-index: 2;
display: flex;
min-inline-size: 0;
align-items: center;
gap: 0.5rem;
font-size: 0.6875rem;
font-weight: 500;
inset-block-start: 0.5rem;
inset-inline: 0.5rem;
pointer-events: none;
}
.subscribe-card-mobile-image-meta__item {
display: flex;
min-inline-size: 0;
align-items: center;
gap: 0.25rem;
color: rgba(255, 255, 255, 0.9);
line-height: 1.2;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95);
}
.subscribe-card-mobile-image-meta__user {
flex: 1 1 auto;
}
.subscribe-card-mobile-image-meta__user span {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subscribe-card-mobile-image-meta__updated {
flex-shrink: 0;
margin-inline-start: auto;
color: rgba(255, 255, 255, 0.84);
}
.subscribe-card-mobile-body {
display: flex;
flex: 1;
flex-direction: column;
padding: 0.25rem 0.625rem 0.375rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.subscribe-card-mobile-title {
position: absolute;
z-index: 2;
color: white;
font-size: 1rem;
font-weight: 650;
inset-block-end: 0;
inset-inline: 0;
line-height: 1.3;
padding: 1rem 0.75rem 0.625rem;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95);
}
.subscribe-card-mobile-title-text {
display: -webkit-box;
max-block-size: 3.9em;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.subscribe-card-mobile-season {
margin-inline-start: 0.25rem;
color: rgba(255, 255, 255, 0.66);
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
}
.subscribe-card-mobile-footer {
display: flex;
flex-direction: column;
gap: 0.1875rem;
margin-block-start: auto;
}
.subscribe-card-mobile-meta {
display: flex;
min-inline-size: 0;
min-block-size: 1.75rem;
align-items: center;
gap: 0.25rem;
justify-content: space-between;
}
.subscribe-card-mobile-state {
display: flex;
min-inline-size: 0;
align-items: center;
flex: 1 1 auto;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.25;
white-space: nowrap;
}
.subscribe-card-mobile-state span {
overflow: hidden;
text-overflow: ellipsis;
}
.subscribe-card-mobile-progress-text {
flex-shrink: 0;
}
.subscribe-card-mobile-menu {
block-size: 1.75rem;
min-block-size: 1.75rem;
inline-size: 1.75rem;
min-inline-size: 1.75rem;
flex: 0 0 1.75rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.subscribe-card-mobile-progress {
display: flex;
block-size: 3px;
inline-size: 100%;
}
.subscribe-card-mobile-progress .v-progress-linear {
flex: 1 1 auto;
}
.subscribe-card-shell--selected::after {
position: absolute;
z-index: 5;
border: 2px solid rgb(var(--v-theme-primary));
border-radius: inherit;
content: '';
inset: 0;
pointer-events: none;
}
.subscribe-card-background {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
/* 缺失封面时的媒体占位背景(图标 + 底色),对齐音乐媒体卡片 */
.subscribe-card-placeholder {
block-size: 100%;
inline-size: 100%;
background: rgba(var(--v-theme-on-surface), 0.08);
}
/* 桌面版占位与图片同高,避免无图时卡片整体塌陷上浮 */
.subscribe-card-placeholder--cover {
aspect-ratio: 3 / 2;
}
/**
* 暂停:降低不透明度表达"已停止活动"
*/
.subscribe-card-paused {
opacity: 0.65;
transition: opacity 0.2s ease;
}
/**
* 待定:内发光挂在实际 VCard 上,跟随卡片圆角并被 overflow-hidden 裁剪。
*/
.subscribe-card-pending-tint {
position: relative;
}
.subscribe-card-pending-tint::after {
position: absolute;
z-index: 3;
border-radius: inherit;
box-shadow: inset 0 0 48px rgba(var(--v-theme-info), 0.28);
content: '';
inset: 0;
pointer-events: none;
}
/**
* 洗版标识:桌面端左上角使用 24x24 圆形徽标。
* 分集:深色半透底 + 模糊
* 全集:磨砂玻璃半透白底 + 大模糊
*/
.best-version-badge {
position: absolute;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
backdrop-filter: blur(6px);
background: rgba(0, 0, 0, 75%);
block-size: 24px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 50%);
inline-size: 24px;
inset-block-start: 6px;
inset-inline-start: 8px;
}
.best-version-badge-full {
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 22%);
box-shadow: 0 2px 8px rgba(255, 255, 255, 15%);
}
@media (width <= 599px) {
.subscribe-card {
min-block-size: 0 !important;
}
.subscribe-card-background.subscribe-card-mobile-image-scrim {
background-image:
linear-gradient(180deg, rgba(8, 12, 18, 0.28) 0%, rgba(8, 12, 18, 0) 44%),
linear-gradient(0deg, rgba(8, 12, 18, 0.7) 0%, rgba(8, 12, 18, 0) 72%);
}
.subscribe-card-paused {
opacity: 1;
}
.subscribe-card-paused .subscribe-card-mobile-media .v-img {
filter: saturate(0.65);
opacity: 0.58;
}
.subscribe-card-pending-tint::after {
box-shadow:
inset 0 0 0 1px rgba(var(--v-theme-info), 0.28),
inset 0 -4rem 5rem rgba(var(--v-theme-info), 0.08);
}
.subscribe-card-best-version-tint {
position: relative;
}
.subscribe-card-best-version-tint::after {
position: absolute;
z-index: 3;
border-radius: inherit;
box-shadow:
inset 0 0 0 1px rgba(var(--v-theme-success), 0.34),
inset 0 -4rem 5rem rgba(var(--v-theme-success), 0.12);
content: '';
inset: 0;
pointer-events: none;
}
}
</style>