mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-11 18:10:49 +08:00
590 lines
15 KiB
Vue
590 lines
15 KiB
Vue
<script lang="ts" setup>
|
||
import type { PropType, Ref } from 'vue'
|
||
import { useToast } from 'vue-toast-notification'
|
||
import SubscribeEditForm from '../form/SubscribeEditForm.vue'
|
||
import { formatSeason } from '@/@core/utils/formatters'
|
||
import api from '@/api'
|
||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
|
||
import router from '@/router'
|
||
import noImage from '@images/no-image.jpeg'
|
||
|
||
// 输入参数
|
||
const props = defineProps({
|
||
media: Object as PropType<MediaInfo>,
|
||
width: String,
|
||
height: String,
|
||
})
|
||
|
||
// 提示框
|
||
const $toast = useToast()
|
||
|
||
// 图片加载状态
|
||
const isImageLoaded = ref(false)
|
||
|
||
// 图片加载失败
|
||
const imageLoadError = ref(false)
|
||
|
||
// TMDB识别标志
|
||
const tmdbFlag = ref(true)
|
||
|
||
// 当前订阅状态
|
||
const isSubscribed = ref(false)
|
||
|
||
// 本地存在状态
|
||
const isExists = ref(false)
|
||
|
||
// 各季缺失状态:0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
|
||
const seasonsNotExisted = ref<{ [key: number]: number }>({})
|
||
|
||
// 订阅季弹窗
|
||
const subscribeSeasonDialog = ref(false)
|
||
|
||
// 订阅编辑弹窗
|
||
const subscribeEditDialog = ref(false)
|
||
|
||
// 订阅ID
|
||
const subscribeId = ref<number>()
|
||
|
||
// 季详情
|
||
const seasonInfos = ref<TmdbSeason[]>([])
|
||
|
||
// 选中的订阅季
|
||
const seasonsSelected = ref<TmdbSeason[]>([])
|
||
|
||
// 订阅弹窗选择的多季
|
||
function subscribeSeasons() {
|
||
subscribeSeasonDialog.value = false
|
||
seasonsSelected.value.forEach((season) => {
|
||
addSubscribe(season.season_number)
|
||
})
|
||
}
|
||
|
||
// 角标颜色
|
||
function getChipColor(type: string) {
|
||
if (type === '电影')
|
||
return 'border-blue-500 bg-blue-600'
|
||
else if (type === '电视剧')
|
||
return ' bg-indigo-500 border-indigo-600'
|
||
else
|
||
return 'border-purple-600 bg-purple-600'
|
||
}
|
||
|
||
// 添加订阅处理
|
||
|
||
async function handleAddSubscribe() {
|
||
if (props.media?.type === '电视剧' && props.media?.tmdb_id) {
|
||
// TMDB电视剧
|
||
// 查询TMDB所有季信息
|
||
await getMediaSeasons()
|
||
if (!seasonInfos.value) {
|
||
$toast.error(`${props.media?.title} 查询剧集信息失败!`)
|
||
return
|
||
}
|
||
|
||
// 检查各季的缺失状态
|
||
await checkSeasonsNotExists()
|
||
if (!tmdbFlag.value)
|
||
return
|
||
|
||
if (seasonInfos.value.length === 1) {
|
||
// 添加订阅
|
||
addSubscribe(1)
|
||
}
|
||
else {
|
||
// 弹出季选择列表,支持多选
|
||
seasonsSelected.value = []
|
||
subscribeSeasonDialog.value = true
|
||
}
|
||
}
|
||
else if (props.media?.type === '电视剧') {
|
||
// 豆瓣电视剧,只会有一季
|
||
const season = props.media?.season ?? 1
|
||
// 添加订阅
|
||
addSubscribe(season)
|
||
}
|
||
else {
|
||
// 电影
|
||
addSubscribe()
|
||
}
|
||
}
|
||
|
||
// 调用API添加订阅,电视剧的话需要指定季
|
||
async function addSubscribe(season = 0) {
|
||
// 开始处理
|
||
startNProgress()
|
||
try {
|
||
// 是否洗版
|
||
let best_version = isExists.value ? 1 : 0
|
||
if (season && props.media?.tmdb_id)
|
||
// 全部存在时洗版
|
||
best_version = !seasonsNotExisted.value[season] ? 1 : 0
|
||
// 请求API
|
||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||
name: props.media?.title,
|
||
type: props.media?.type,
|
||
year: props.media?.year,
|
||
tmdbid: props.media?.tmdb_id,
|
||
doubanid: props.media?.douban_id,
|
||
season,
|
||
best_version,
|
||
})
|
||
|
||
// 订阅状态
|
||
if (result.success) {
|
||
// 订阅成功
|
||
isSubscribed.value = true
|
||
}
|
||
|
||
// 提示
|
||
showSubscribeAddToast(
|
||
result.success,
|
||
props.media?.title ?? '',
|
||
season,
|
||
result.message,
|
||
best_version,
|
||
)
|
||
|
||
// 弹出订阅编辑弹窗
|
||
if (result.success && seasonsSelected.value.length <= 1) {
|
||
subscribeId.value = result.data.id
|
||
subscribeEditDialog.value = true
|
||
}
|
||
}
|
||
catch (error) {
|
||
console.error(error)
|
||
}
|
||
doneNProgress()
|
||
}
|
||
|
||
// 弹出添加订阅提示
|
||
function showSubscribeAddToast(result: boolean,
|
||
title: string,
|
||
season: number,
|
||
message: string,
|
||
best_version: number) {
|
||
if (season)
|
||
title = `${title} ${formatSeason(season.toString())}`
|
||
|
||
let subname = '订阅'
|
||
if (best_version > 0)
|
||
subname = '洗版订阅'
|
||
|
||
if (result && seasonsSelected.value.length > 1)
|
||
$toast.success(`${title} 添加${subname}成功!`)
|
||
else if (!result)
|
||
$toast.error(`${title} 添加${subname}失败:${message}!`)
|
||
}
|
||
|
||
// 调用API取消订阅
|
||
async function removeSubscribe() {
|
||
// 开始处理
|
||
startNProgress()
|
||
try {
|
||
const mediaid = props.media?.tmdb_id
|
||
? `tmdb:${props.media?.tmdb_id}`
|
||
: `douban:${props.media?.douban_id}`
|
||
|
||
const result: { [key: string]: any } = await api.delete(
|
||
`subscribe/media/${mediaid}`,
|
||
{
|
||
params: {
|
||
season: props.media?.season,
|
||
},
|
||
},
|
||
)
|
||
|
||
if (result.success) {
|
||
isSubscribed.value = false
|
||
$toast.success(`${props.media?.title} 已取消订阅!`)
|
||
}
|
||
else {
|
||
$toast.error(`${props.media?.title} 取消订阅失败:${result.message}!`)
|
||
}
|
||
}
|
||
catch (error) {
|
||
console.error(error)
|
||
}
|
||
doneNProgress()
|
||
}
|
||
|
||
// 查询当前媒体是否已订阅
|
||
async function handleCheckSubscribe() {
|
||
try {
|
||
const result = await checkSubscribe(props.media?.season)
|
||
if (result)
|
||
isSubscribed.value = true
|
||
}
|
||
catch (error) {
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 查询当前媒体是否已入库
|
||
async function handleCheckExists() {
|
||
try {
|
||
const result: { [key: string]: any } = await api.get('media/exists', {
|
||
params: {
|
||
tmdbid: props.media?.tmdb_id,
|
||
title: props.media?.title,
|
||
year: props.media?.year,
|
||
season: props.media?.season,
|
||
mtype: props.media?.type,
|
||
},
|
||
})
|
||
|
||
if (result.success)
|
||
isExists.value = true
|
||
}
|
||
catch (error) {
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 调用API检查是否已订阅,电视剧需要指定季
|
||
async function checkSubscribe(season = 0) {
|
||
try {
|
||
const mediaid = props.media?.tmdb_id
|
||
? `tmdb:${props.media?.tmdb_id}`
|
||
: `douban:${props.media?.douban_id}`
|
||
|
||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||
params: {
|
||
season,
|
||
title: props.media?.title,
|
||
},
|
||
})
|
||
|
||
return result.id || null
|
||
}
|
||
catch (error) {
|
||
console.error(error)
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
// 检查所有季的缺失状态
|
||
async function checkSeasonsNotExists() {
|
||
// 开始处理
|
||
startNProgress()
|
||
try {
|
||
const result: NotExistMediaInfo[] = await api.post('download/notexists', props.media)
|
||
if (result) {
|
||
result.forEach((item) => {
|
||
// 0-已入库 1-部分缺失 2-全部缺失
|
||
let state = 0
|
||
if (item.episodes.length === 0)
|
||
state = 2
|
||
else if (item.episodes.length < item.total_episode)
|
||
state = 1
|
||
|
||
seasonsNotExisted.value[item.season] = state
|
||
})
|
||
}
|
||
}
|
||
catch (error) {
|
||
$toast.error(`${props.media?.title}无法识别TMDB媒体信息!`)
|
||
tmdbFlag.value = false
|
||
}
|
||
|
||
// 处理完成
|
||
doneNProgress()
|
||
}
|
||
|
||
// 查询TMDB的所有季信息
|
||
async function getMediaSeasons() {
|
||
try {
|
||
seasonInfos.value = await api.get(`tmdb/seasons/${props.media?.tmdb_id}`)
|
||
}
|
||
catch (error) {
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 爱心订阅按钮响应
|
||
function handleSubscribe() {
|
||
if (isSubscribed.value)
|
||
removeSubscribe()
|
||
else
|
||
handleAddSubscribe()
|
||
}
|
||
|
||
// 计算存在状态的颜色
|
||
function getExistColor(season: number) {
|
||
const state = seasonsNotExisted.value[season]
|
||
if (!state)
|
||
return 'success'
|
||
|
||
if (state === 1)
|
||
return 'warning'
|
||
else if (state === 2)
|
||
return 'error'
|
||
else
|
||
return 'success'
|
||
}
|
||
|
||
// 计算存在状态的文本
|
||
function getExistText(season: number) {
|
||
const state = seasonsNotExisted.value[season]
|
||
if (!state)
|
||
return '已入库'
|
||
|
||
if (state === 1)
|
||
return '部分缺失'
|
||
else if (state === 2)
|
||
return '缺失'
|
||
else
|
||
return '已入库'
|
||
}
|
||
|
||
// 打开详情页
|
||
function goMediaDetail() {
|
||
router.push({
|
||
path: '/media',
|
||
query: {
|
||
mediaid: `${
|
||
props.media?.tmdb_id
|
||
? `tmdb:${props.media?.tmdb_id}`
|
||
: `douban:${props.media?.douban_id}`
|
||
}`,
|
||
type: props.media?.type,
|
||
},
|
||
})
|
||
}
|
||
|
||
// 开始搜索
|
||
function handleSearch() {
|
||
router.push({
|
||
path: '/resource',
|
||
query: {
|
||
keyword: `${
|
||
props.media?.tmdb_id
|
||
? `tmdb:${props.media?.tmdb_id}`
|
||
: `douban:${props.media?.douban_id}`
|
||
}`,
|
||
type: props.media?.type,
|
||
area: 'title',
|
||
},
|
||
})
|
||
}
|
||
|
||
// 装载时检查是否已订阅
|
||
onBeforeMount(() => {
|
||
handleCheckSubscribe()
|
||
handleCheckExists()
|
||
})
|
||
|
||
// 计算图片地址
|
||
const getImgUrl: Ref<string> = computed(() => {
|
||
if (imageLoadError.value)
|
||
return noImage
|
||
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
|
||
// 如果地址中包含douban则使用中转代理
|
||
if (url.includes('doubanio.com'))
|
||
return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}`
|
||
|
||
return url
|
||
})
|
||
|
||
// 拼装季图片地址
|
||
function getSeasonPoster(posterPath: string) {
|
||
if (!posterPath)
|
||
return ''
|
||
return `https://image.tmdb.org/t/p/w500${posterPath}`
|
||
}
|
||
|
||
// 将yyyy-mm-dd转换为yyyy年mm月dd日
|
||
function formatAirDate(airDate: string) {
|
||
if (!airDate)
|
||
return ''
|
||
const date = new Date(airDate)
|
||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||
}
|
||
// 从yyyy-mm-dd中提取年份
|
||
function getYear(airDate: string) {
|
||
if (!airDate)
|
||
return ''
|
||
const date = new Date(airDate)
|
||
return date.getFullYear()
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<VHover v-bind="props">
|
||
<template #default="hover">
|
||
<VCard
|
||
v-bind="hover.props"
|
||
:height="props.height"
|
||
:width="props.width"
|
||
class="outline-none shadow ring-gray-500 rounded-lg"
|
||
:class="{
|
||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||
'ring-1': isImageLoaded,
|
||
}"
|
||
>
|
||
<VImg
|
||
aspect-ratio="2/3"
|
||
:src="getImgUrl"
|
||
class="object-cover aspect-w-2 aspect-h-3"
|
||
:class="hover.isHovering ? 'on-hover' : ''"
|
||
cover
|
||
@load="isImageLoaded = true"
|
||
@error="imageLoadError = true"
|
||
>
|
||
<template #placeholder>
|
||
<div class="w-full h-full">
|
||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||
</div>
|
||
</template>
|
||
<!-- 类型角标 -->
|
||
<VChip
|
||
v-show="isImageLoaded"
|
||
variant="elevated"
|
||
size="small"
|
||
:class="getChipColor(props.media?.type || '')"
|
||
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||
>
|
||
{{ props.media?.type }}
|
||
</VChip>
|
||
<!-- 本地存在标识 -->
|
||
<ExistIcon v-if="isExists" />
|
||
<!-- 评分角标 -->
|
||
<VChip
|
||
v-if="isImageLoaded && props.media?.vote_average && !isExists"
|
||
variant="elevated"
|
||
size="small"
|
||
:class="getChipColor('rating')"
|
||
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||
>
|
||
{{ props.media?.vote_average }}
|
||
</VChip>
|
||
<!-- 详情 -->
|
||
<VCardText
|
||
v-show="hover.isHovering || imageLoadError"
|
||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||
@click.stop="goMediaDetail"
|
||
>
|
||
<span class="font-bold">{{ props.media?.year }}</span>
|
||
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||
{{ props.media?.title }}
|
||
</h1>
|
||
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
|
||
{{ props.media?.overview }}
|
||
</p>
|
||
<div class="flex align-center justify-between">
|
||
<IconBtn
|
||
icon="mdi-magnify"
|
||
color="white"
|
||
@click.stop="handleSearch"
|
||
/>
|
||
<IconBtn
|
||
icon="mdi-heart"
|
||
:color="isSubscribed ? 'error' : 'white'"
|
||
@click.stop="handleSubscribe"
|
||
/>
|
||
</div>
|
||
</VCardText>
|
||
</VImg>
|
||
</VCard>
|
||
</template>
|
||
</VHover>
|
||
<!-- 订阅季弹窗 -->
|
||
<VBottomSheet
|
||
v-model="subscribeSeasonDialog"
|
||
inset
|
||
scrollable
|
||
>
|
||
<VCard class="rounded-t">
|
||
<DialogCloseBtn @click="subscribeSeasonDialog = false" />
|
||
<VCardTitle class="pe-10">
|
||
订阅 - {{ props.media?.title }}
|
||
</VCardTitle>
|
||
<VCardText>
|
||
<VList
|
||
v-model:selected="seasonsSelected"
|
||
lines="three"
|
||
select-strategy="classic"
|
||
>
|
||
<VListItem
|
||
v-for="(item, i) in seasonInfos" :key="i"
|
||
:value="item"
|
||
>
|
||
<template #prepend>
|
||
<VImg
|
||
height="90"
|
||
width="60"
|
||
:src="getSeasonPoster(item.poster_path || '')"
|
||
aspect-ratio="2/3"
|
||
class="object-cover rounded shadow ring-gray-500 me-3"
|
||
cover
|
||
>
|
||
<template #placeholder>
|
||
<div class="w-full h-full">
|
||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||
</div>
|
||
</template>
|
||
</VImg>
|
||
</template>
|
||
<VListItemTitle>
|
||
第 {{ item.season_number }} 季
|
||
</VListItemTitle>
|
||
<VListItemSubtitle class="mt-1 me-2">
|
||
<VChip
|
||
v-if="item.vote_average"
|
||
color="primary"
|
||
size="small"
|
||
class="mb-1"
|
||
>
|
||
<VIcon icon="mdi-star" /> {{ item.vote_average }}
|
||
</VChip>
|
||
{{ getYear(item.air_date || '') }} • {{ item.episode_count }} 集
|
||
</VListItemSubtitle>
|
||
<VListItemSubtitle>
|
||
《{{ media?.title }}》第 {{ item.season_number }} 季于 {{ formatAirDate(item.air_date || '') }} 首播。
|
||
</VListItemSubtitle>
|
||
<VListItemSubtitle>
|
||
<VChip
|
||
v-if="seasonsNotExisted"
|
||
class="mt-2"
|
||
size="small"
|
||
:color="getExistColor(item.season_number || 0)"
|
||
>
|
||
{{ getExistText(item.season_number || 0) }}
|
||
</VChip>
|
||
</VListItemSubtitle>
|
||
<template #append="{ isSelected }">
|
||
<VListItemAction start>
|
||
<VSwitch :model-value="isSelected" />
|
||
</VListItemAction>
|
||
</template>
|
||
</VListItem>
|
||
</VList>
|
||
</VCardText>
|
||
<div class="my-2 text-center">
|
||
<VBtn
|
||
:disabled="seasonsSelected.length === 0"
|
||
width="30%"
|
||
@click="subscribeSeasons"
|
||
>
|
||
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
|
||
</VBtn>
|
||
</div>
|
||
</VCard>
|
||
</VBottomSheet>
|
||
<!-- 订阅编辑弹窗 -->
|
||
<SubscribeEditForm
|
||
v-model="subscribeEditDialog"
|
||
:subid="subscribeId"
|
||
@close="subscribeEditDialog = false"
|
||
@save="subscribeEditDialog = false"
|
||
@remove="() => { subscribeEditDialog = false; handleCheckSubscribe(); }"
|
||
/>
|
||
</template>
|
||
|
||
<style lang="scss">
|
||
.on-hover img {
|
||
@apply brightness-50;
|
||
}
|
||
</style>
|