Files
BYW-VoiceHub/app/components/Songs/BilibiliEpisodesModal.vue
T

403 lines
14 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.
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
@click.self="close"
>
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
<div
class="relative w-full max-w-2xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
@click.stop
>
<!-- 头部 -->
<div class="flex items-center justify-between p-8 pb-4">
<div class="flex items-center gap-4 min-w-0">
<div class="w-12 h-12 rounded-2xl overflow-hidden flex-shrink-0 bg-bg-tertiary">
<img
v-if="video?.cover"
:src="video.cover"
:alt="video.title"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
>
<div
v-else
class="w-full h-full bg-primary-hover-10 flex items-center justify-center text-primary"
>
<Icon name="play" :size="24" />
</div>
</div>
<div class="min-w-0">
<h3 class="text-xl font-black text-text-primary tracking-tight truncate">
{{ video?.title }}
</h3>
<p class="text-xs font-bold text-text-tertiary uppercase tracking-widest mt-1">
{{ locale.uploader }}{{ video?.artist }}
</p>
</div>
</div>
<button
class="w-10 h-10 flex items-center justify-center rounded-xl bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all flex-shrink-0"
@click="close"
>
<Icon name="x" :size="20" />
</button>
</div>
<!-- 主体 -->
<div class="flex-1 overflow-y-auto p-8 pt-4 custom-scrollbar">
<div
v-if="episodes.length === 0"
class="flex flex-col items-center justify-center py-12 text-text-tertiary"
>
<div
class="w-16 h-16 rounded-3xl bg-bg-tertiary-50 flex items-center justify-center mb-4"
>
<Icon name="play" :size="32" class="opacity-20" />
</div>
<p class="text-sm font-bold uppercase tracking-widest">{{ locale.empty }}</p>
</div>
<div v-else class="program-list space-y-3">
<div
v-for="episode in episodes"
:key="episode.cid"
class="group flex items-center p-3 sm:p-4 rounded-3xl transition-all"
:class="[
isCurrentEpisode(episode)
? 'bg-primary-hover-10 border border-primary-50'
: 'bg-bg-tertiary-30 border border-border-secondary-50 hover:bg-bg-tertiary-50 hover:border-border-tertiary'
]"
>
<!-- 剧集编号 -->
<div
class="w-8 h-8 sm:w-12 sm:h-12 rounded-2xl bg-bg-tertiary flex items-center justify-center text-text-tertiary font-black text-xs sm:text-sm flex-shrink-0 mr-3 sm:mr-4 group-hover:text-text-secondary transition-colors"
>
P{{ episode.page }}
</div>
<!-- 剧集信息 -->
<div class="flex-1 min-w-0">
<h4
class="font-bold text-text-primary text-sm sm:text-base whitespace-normal sm:whitespace-nowrap sm:truncate line-clamp-2 sm:line-clamp-none group-hover:text-text-primary transition-colors"
>
{{ episode.part }}
</h4>
<div
class="flex items-center gap-3 mt-1 text-[10px] text-text-tertiary font-bold uppercase tracking-wider"
>
<span class="flex items-center">
<Icon name="clock" :size="10" class="mr-1" />
{{ formatDuration(episode.duration) }}
</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="ml-2 sm:ml-4 shrink-0 flex items-center gap-2 sm:gap-3">
<!-- 预听/暂停按钮 -->
<button
class="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl bg-bg-tertiary text-text-tertiary hover:bg-bg-quaternary hover:text-text-primary transition-all active:scale-95"
:title="isCurrentEpisode(episode) && isPlaying ? locale.pause : locale.preview"
@click.stop="togglePlay(episode)"
>
<Icon
:name="isCurrentEpisode(episode) && isPlaying ? 'pause' : 'play'"
:size="16"
/>
</button>
<template v-if="getEpisodeStatus(episode).played">
<button
v-if="allowPlayedResubmit"
:disabled="submitting"
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-[10px] sm:text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-[var(--primary-glow)]"
@click.stop="selectEpisode(episode)"
>
<span v-if="submitting && selectedEpisodeCid === episode.cid">{{ locale.submitting }}</span>
<span v-else><span class="hidden sm:inline">{{ locale.select }}</span>{{ locale.submit }}</span>
</button>
<!-- 已播放标签 -->
<div
v-else
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-success-10 text-success border border-success-20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
>
{{ locale.played }}
</div>
</template>
<template v-else-if="getEpisodeStatus(episode).scheduled">
<!-- 已排期标签 -->
<div
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-warning-10 text-warning border border-warning-20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
>
{{ locale.scheduled }}
</div>
</template>
<template v-else-if="getEpisodeStatus(episode).submitted">
<div class="flex flex-col gap-2 items-center">
<!-- 已投稿标签 -->
<div
class="w-full text-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-bg-tertiary text-text-tertiary text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
>
{{ locale.submitted }}
</div>
<!-- 点赞按钮(非自己投稿) -->
<button
v-if="!isMyEpisode(episode)"
:disabled="getEpisodeStatus(episode).voted || submitting"
:class="[
'w-full px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest transition-all active:scale-95',
getEpisodeStatus(episode).voted
? 'bg-error-10 text-error border border-error-20 cursor-not-allowed'
: 'bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary hover:text-error'
]"
@click.stop="voteEpisode(episode)"
>
<span class="flex items-center justify-center gap-1">
<Icon
name="heart"
:size="12"
:class="getEpisodeStatus(episode).voted ? 'fill-current' : ''"
/>
<span>{{ getEpisodeStatus(episode).voted ? locale.liked : locale.like }}</span>
</span>
</button>
</div>
</template>
<template v-else>
<!-- 选择投稿按钮(未投稿) -->
<button
:disabled="submitting"
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-[10px] sm:text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-[var(--primary-glow)]"
@click.stop="selectEpisode(episode)"
>
<span v-if="submitting && selectedEpisodeCid === episode.cid">{{ locale.submitting }}</span>
<span v-else><span class="hidden sm:inline">{{ locale.select }}</span>{{ locale.submit }}</span>
</button>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, computed } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import { useAudioPlayer } from '~/composables/useAudioPlayer'
import { isBilibiliSong } from '~/utils/bilibiliSource'
import { useLocale } from '~/utils/locale'
const { songs } = useLocale()
const locale = computed(() => songs.value?.bilibiliEpisodesModal || {})
const props = defineProps({
show: Boolean,
video: Object,
episodes: {
type: Array,
default: () => []
},
submittedEpisodes: {
type: Array,
default: () => []
},
currentUserId: {
type: Number,
default: null
},
allowPlayedResubmit: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'submit', 'play', 'vote'])
const { getCurrentSong, getPlayingStatus, pauseSong } = useAudioPlayer()
const currentSong = getCurrentSong()
const isPlaying = getPlayingStatus()
const submitting = ref(false)
const selectedEpisodeCid = ref(null)
// 计算已投稿状态
const submittedCount = computed(() => props.submittedEpisodes.length)
const totalEpisodes = computed(() => props.episodes.length)
const formatDuration = (seconds) => {
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
const isCurrentEpisode = (episode) => {
if (!currentSong.value) return false
if (!isBilibiliSong(currentSong.value)) return false
// 检查 CID
if (
currentSong.value.bilibiliCid &&
String(currentSong.value.bilibiliCid) === String(episode.cid)
) {
return true
}
// 兼容逻辑:检查 musicId 格式是否为 bvid:cid
if (currentSong.value.musicId && currentSong.value.musicId.includes(':')) {
const [, cid] = currentSong.value.musicId.split(':')
return cid === String(episode.cid)
}
return false
}
// 获取 episode 的完整状态(包括 voted)
const getEpisodeStatus = (episode) => {
const submittedSong = props.submittedEpisodes.find((song) => {
if (!song.musicId) return false
const cid = song.musicId.includes(':') ? song.musicId.split(':')[1] : null
return cid === String(episode.cid)
})
if (submittedSong) {
return {
submitted: true,
voted: submittedSong.voted || false,
songId: submittedSong.id,
requesterId: submittedSong.requesterId,
played: submittedSong.played || false,
scheduled: submittedSong.scheduled || false
}
}
return {
submitted: false,
voted: false,
songId: null,
requesterId: null,
played: episode.played || false,
scheduled: episode.scheduled || false
}
}
const isMyEpisode = (episode) => {
if (!props.currentUserId) return false
// 使用 getEpisodeStatus 获取统一的状态信息,避免直接修改 props
const status = getEpisodeStatus(episode)
return status.requesterId === props.currentUserId
}
const togglePlay = (episode) => {
if (isCurrentEpisode(episode) && isPlaying.value) {
pauseSong()
} else {
playEpisode(episode)
}
}
const playEpisode = (episode) => {
const episodeData = {
...episode,
cover: props.video?.cover,
bvid: props.video?.id,
title: props.video?.title,
artist: props.video?.artist
}
// 构建所有分P的播放列表
const playlist = props.episodes.map(e => ({
...e,
cover: props.video?.cover,
bvid: props.video?.id,
title: props.video?.title,
artist: props.video?.artist,
musicPlatform: 'bilibili',
musicId: props.video?.id,
bilibiliCid: e.cid
}))
const currentIndex = playlist.findIndex(e => e.cid === episode.cid)
emit('play', { song: episodeData, playlist, playlistIndex: currentIndex })
}
const selectEpisode = (episode) => {
const status = getEpisodeStatus(episode)
if (submitting.value || (status.submitted && !(props.allowPlayedResubmit && status.played))) return
submitting.value = true
selectedEpisodeCid.value = episode.cid
emit('submit', { episode, status })
}
const voteEpisode = (episode) => {
if (submitting.value) {
return
}
// 使用 getEpisodeStatus 获取统一的状态信息,避免重复查找
const { songId, voted } = getEpisodeStatus(episode)
if (!songId || voted) {
return
}
emit('vote', { ...episode, songId })
}
const close = () => {
emit('close')
// 重置状态
submitting.value = false
selectedEpisodeCid.value = null
}
// 暴露方法给父组件,用于在提交失败时重置状态
const resetSubmissionState = () => {
submitting.value = false
selectedEpisodeCid.value = null
}
defineExpose({
resetSubmissionState
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--overlay-10);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--overlay-20);
}
</style>