初次提交
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
<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-black/60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-2xl bg-zinc-900 border border-zinc-800 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-zinc-800">
|
||||
<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-blue-600/10 flex items-center justify-center text-blue-500"
|
||||
>
|
||||
<Icon name="play" :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight truncate">
|
||||
{{ video?.title }}
|
||||
</h3>
|
||||
<p class="text-xs font-bold text-zinc-500 uppercase tracking-widest mt-1">
|
||||
UP主:{{ video?.artist }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 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-zinc-500"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-zinc-800/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">暂无剧集</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-blue-600/10 border border-blue-500/50'
|
||||
: 'bg-zinc-800/30 border border-zinc-800/50 hover:bg-zinc-800/50 hover:border-zinc-700'
|
||||
]"
|
||||
>
|
||||
<!-- 剧集编号 -->
|
||||
<div
|
||||
class="w-8 h-8 sm:w-12 sm:h-12 rounded-2xl bg-zinc-800 flex items-center justify-center text-zinc-500 font-black text-xs sm:text-sm flex-shrink-0 mr-3 sm:mr-4 group-hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
P{{ episode.page }}
|
||||
</div>
|
||||
|
||||
<!-- 剧集信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-zinc-100 text-sm sm:text-base whitespace-normal sm:whitespace-nowrap sm:truncate line-clamp-2 sm:line-clamp-none group-hover:text-white transition-colors"
|
||||
>
|
||||
{{ episode.part }}
|
||||
</h4>
|
||||
<div
|
||||
class="flex items-center gap-3 mt-1 text-[10px] text-zinc-500 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-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-100 transition-all active:scale-95"
|
||||
:title="isCurrentEpisode(episode) && isPlaying ? '暂停' : '预听'"
|
||||
@click.stop="togglePlay(episode)"
|
||||
>
|
||||
<Icon
|
||||
:name="isCurrentEpisode(episode) && isPlaying ? 'pause' : 'play'"
|
||||
:size="16"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<template v-if="getEpisodeStatus(episode).played">
|
||||
<!-- 已播放标签 -->
|
||||
<div
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
>
|
||||
已播放
|
||||
</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-amber-500/10 text-amber-500 border border-amber-500/20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
>
|
||||
已排期
|
||||
</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-zinc-800 text-zinc-500 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
>
|
||||
已投稿
|
||||
</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-red-500/10 text-red-500 border border-red-500/20 cursor-not-allowed'
|
||||
: 'bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-red-500'
|
||||
]"
|
||||
@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 ? '已点赞' : '点赞' }}</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-blue-600 hover:bg-blue-500 text-white text-[10px] sm:text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-blue-900/20"
|
||||
@click.stop="selectEpisode(episode)"
|
||||
>
|
||||
<span v-if="submitting && selectedEpisodeCid === episode.cid">提交中...</span>
|
||||
<span v-else><span class="hidden sm:inline">选择</span>投稿</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'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
video: Object,
|
||||
episodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
submittedEpisodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const isEpisodeSubmitted = (episode) => {
|
||||
return props.submittedEpisodes.some((song) => {
|
||||
if (!song.musicId) return false
|
||||
const cid = song.musicId.includes(':') ? song.musicId.split(':')[1] : null
|
||||
return cid === String(episode.cid)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取 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
|
||||
}
|
||||
emit('play', episodeData)
|
||||
}
|
||||
|
||||
const selectEpisode = (episode) => {
|
||||
if (submitting.value || isEpisodeSubmitted(episode)) return
|
||||
submitting.value = true
|
||||
selectedEpisodeCid.value = episode.cid
|
||||
emit('submit', episode)
|
||||
}
|
||||
|
||||
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: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<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-[60] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900 border border-zinc-800 w-full max-w-lg rounded-3xl overflow-hidden shadow-2xl flex flex-col"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="p-8 pb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="20" />
|
||||
</div>
|
||||
歌曲已存在
|
||||
</h3>
|
||||
<p class="text-xs text-zinc-500 mt-1 ml-13">这首歌曲已经在列表中了,不能重复投稿</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-8 pt-4 space-y-6">
|
||||
<p class="text-sm text-zinc-400 leading-relaxed font-medium">
|
||||
您可以为它点赞支持,或者尝试搜索其他歌曲!
|
||||
</p>
|
||||
|
||||
<!-- 歌曲卡片 -->
|
||||
<div
|
||||
class="flex gap-4 p-5 bg-zinc-950 border border-zinc-800 rounded-3xl group shadow-xl"
|
||||
>
|
||||
<div
|
||||
class="w-20 h-20 rounded-2xl overflow-hidden bg-zinc-900 flex-shrink-0 border border-zinc-800"
|
||||
>
|
||||
<img
|
||||
v-if="song.cover"
|
||||
:alt="song.title"
|
||||
:src="convertToHttps(song.cover)"
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700">
|
||||
<Icon name="music" :size="32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 py-1">
|
||||
<h4 class="text-base font-black text-zinc-100 truncate mb-1">{{ song.title }}</h4>
|
||||
<p
|
||||
class="text-[10px] font-black uppercase tracking-widest text-zinc-500 truncate mb-3"
|
||||
>
|
||||
{{ song.artist }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-[10px] font-black text-red-500 uppercase tracking-widest"
|
||||
>
|
||||
<Icon name="heart" :size="12" class="fill-current" />
|
||||
{{ song.voteCount || 0 }} 票
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-widest border',
|
||||
song.played
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
]"
|
||||
>
|
||||
{{ song.played ? '已播放' : '待播放' }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-zinc-600 font-black uppercase tracking-widest mt-2">
|
||||
投稿者:{{ song.requester }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="p-8 pt-0 flex gap-3">
|
||||
<button
|
||||
class="flex-1 px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest active:scale-95"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
返回重选
|
||||
</button>
|
||||
<button
|
||||
:disabled="liking || song.voted"
|
||||
class="flex-[2] px-6 py-4 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-2xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
@click="handleLike"
|
||||
>
|
||||
<Icon v-if="liking" name="loader" :size="16" class="animate-spin" />
|
||||
<Icon v-else name="heart" :size="16" :class="[song.voted ? 'fill-current' : '']" />
|
||||
{{ song.voted ? '已点赞' : '立即点赞' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import type { Song } from '~/types'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
song: Song
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
like: [songId: number]
|
||||
}>()
|
||||
|
||||
const liking = ref(false)
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleImageError = (event: Event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}
|
||||
|
||||
const handleLike = async () => {
|
||||
if (liking.value || props.song.voted) return
|
||||
|
||||
liking.value = true
|
||||
try {
|
||||
emit('like', props.song.id)
|
||||
} finally {
|
||||
liking.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,484 @@
|
||||
<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-[2000] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
@click.self="close"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900 border border-zinc-800 w-full max-w-2xl rounded-3xl overflow-hidden shadow-2xl flex flex-col max-h-[85vh]"
|
||||
>
|
||||
<!-- 结果展示视图 -->
|
||||
<div
|
||||
v-if="importResult"
|
||||
class="flex flex-col flex-1 overflow-hidden animate-in fade-in duration-500"
|
||||
>
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-emerald-500/10 flex items-center justify-center text-emerald-500"
|
||||
>
|
||||
<Icon name="success" :size="20" />
|
||||
</div>
|
||||
导入结果
|
||||
</h3>
|
||||
<p class="text-xs text-zinc-500 mt-1 ml-13">歌曲导入操作已完成</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-8 space-y-8 custom-scrollbar">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
class="p-6 bg-emerald-500/5 border border-emerald-500/20 rounded-3xl flex flex-col items-center gap-2"
|
||||
>
|
||||
<span class="text-[10px] font-black text-emerald-500 uppercase tracking-widest"
|
||||
>成功导入</span
|
||||
>
|
||||
<span class="text-4xl font-black text-emerald-400">{{
|
||||
importResult.success
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
class="p-6 bg-red-500/5 border border-red-500/20 rounded-3xl flex flex-col items-center gap-2"
|
||||
>
|
||||
<span class="text-[10px] font-black text-red-500 uppercase tracking-widest"
|
||||
>失败/跳过</span
|
||||
>
|
||||
<span class="text-4xl font-black text-red-400">{{ importResult.failed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importResult.details && importResult.details.length > 0" class="space-y-4">
|
||||
<div
|
||||
class="flex items-center gap-2 text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>
|
||||
<Icon name="info" :size="12" />
|
||||
详细处理记录
|
||||
</div>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div
|
||||
v-for="(detail, index) in importResult.details"
|
||||
:key="index"
|
||||
class="p-3 bg-zinc-950 border border-zinc-800 rounded-xl text-xs text-zinc-400 font-medium"
|
||||
>
|
||||
{{ detail }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center py-10 text-emerald-500/50"
|
||||
>
|
||||
<Icon name="success" :size="48" class="mb-4" />
|
||||
<p class="text-sm font-bold uppercase tracking-widest">全部歌曲导入成功!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-8 pt-4 border-t border-zinc-800/50 bg-zinc-900/50">
|
||||
<button
|
||||
class="w-full px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest shadow-lg active:scale-95"
|
||||
@click="close"
|
||||
>
|
||||
完成并返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 正常导入视图 -->
|
||||
<div v-else class="flex flex-col flex-1 overflow-hidden">
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
|
||||
>
|
||||
<Icon name="download" :size="20" />
|
||||
</div>
|
||||
从历史学期导入
|
||||
</h3>
|
||||
<p class="text-xs text-zinc-500 mt-1 ml-13">选择以往学期的歌曲并导入到当前学期</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-8 py-6 space-y-6 border-b border-zinc-800/50">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>选择学期</label
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="selectedSemester"
|
||||
:options="semesterList"
|
||||
label-key="name"
|
||||
value-key="name"
|
||||
placeholder="请选择目标学期"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex bg-zinc-950 p-1 rounded-xl border border-zinc-800">
|
||||
<button
|
||||
v-for="type in ['unplayed', 'played', 'all']"
|
||||
:key="type"
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all',
|
||||
filterType === type
|
||||
? 'bg-blue-600 text-white shadow-lg'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
]"
|
||||
@click="filterType = type"
|
||||
>
|
||||
{{ getFilterLabel(type) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar bg-zinc-950/30">
|
||||
<div
|
||||
v-if="loadingSemesters || loadingSongs"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-500"
|
||||
>
|
||||
<Icon name="refresh" :size="32" class="animate-spin mb-4 text-blue-500" />
|
||||
<div class="text-[10px] font-black uppercase tracking-widest">正在加载歌曲...</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex flex-col items-center justify-center py-20 text-center px-8"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center text-red-500 mb-4"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="32" />
|
||||
</div>
|
||||
<p class="text-sm text-zinc-400 mb-6">{{ error }}</p>
|
||||
<button
|
||||
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="loadSongs"
|
||||
>
|
||||
重试加载
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!selectedSemester"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-600"
|
||||
>
|
||||
<Icon name="list" :size="48" class="mb-4 opacity-20" />
|
||||
<p class="text-sm font-bold uppercase tracking-widest">请先选择一个学期</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredSongs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-600"
|
||||
>
|
||||
<Icon name="search" :size="48" class="mb-4 opacity-20" />
|
||||
<p class="text-sm font-bold uppercase tracking-widest">没有找到符合条件的歌曲</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-2">
|
||||
<div
|
||||
v-for="song in filteredSongs"
|
||||
:key="song.id"
|
||||
class="group flex items-center gap-4 p-3 rounded-2xl cursor-pointer transition-all border"
|
||||
:class="[
|
||||
isSelected(song.id)
|
||||
? 'bg-blue-600/10 border-blue-500/30 shadow-lg'
|
||||
: 'bg-zinc-900 border-transparent hover:border-zinc-800'
|
||||
]"
|
||||
@click="toggleSelection(song.id)"
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-all"
|
||||
:class="[
|
||||
isSelected(song.id)
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'border-zinc-800 text-transparent'
|
||||
]"
|
||||
>
|
||||
<Icon name="check" :size="14" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl overflow-hidden bg-zinc-800 flex-shrink-0 shadow-md"
|
||||
>
|
||||
<img
|
||||
:src="convertToHttps(song.cover)"
|
||||
alt="cover"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-black text-zinc-100 truncate mb-0.5">
|
||||
{{ song.title }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-zinc-500 truncate">{{ song.artist }}</span>
|
||||
<span class="text-[10px] text-zinc-700 font-bold">•</span>
|
||||
<span class="text-[10px] text-zinc-600 truncate">{{ song.requester }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-tighter border',
|
||||
song.played
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-zinc-800 text-zinc-500 border-zinc-700'
|
||||
]"
|
||||
>
|
||||
{{ song.played ? '已收录' : '未收录' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-8 border-t border-zinc-800/50 bg-zinc-900/50 flex flex-col md:flex-row gap-4 items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-4 w-full md:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="text-[10px] font-black text-blue-500 hover:text-blue-400 uppercase tracking-widest transition-colors"
|
||||
@click="toggleSelectAll"
|
||||
>
|
||||
{{ isAllSelected ? '取消全选' : '选择当前全部' }}
|
||||
</button>
|
||||
<div class="h-4 w-px bg-zinc-800 hidden md:block" />
|
||||
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest">
|
||||
已选择 <span class="text-blue-500">{{ selectedSongIds.size }}</span> 首歌曲
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 w-full md:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 md:flex-none px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
|
||||
@click="close"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-[2] md:flex-none px-8 py-4 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg shadow-blue-900/20 active:scale-95"
|
||||
:disabled="selectedSongIds.size === 0 || importing"
|
||||
@click="handleImport"
|
||||
>
|
||||
<Icon v-if="importing" name="refresh" :size="16" class="animate-spin" />
|
||||
<Icon v-else name="download" :size="16" />
|
||||
{{ importing ? '正在导入...' : '确认导入' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'import-success'])
|
||||
|
||||
const semesterList = ref([])
|
||||
const selectedSemester = ref('')
|
||||
const songs = ref([])
|
||||
const loadingSemesters = ref(false)
|
||||
const loadingSongs = ref(false)
|
||||
const error = ref('')
|
||||
const filterType = ref('unplayed') // '未收录', '已收录', '全部'
|
||||
const selectedSongIds = ref(new Set())
|
||||
const importing = ref(false)
|
||||
const importResult = ref(null)
|
||||
|
||||
const getFilterLabel = (type) => {
|
||||
const map = {
|
||||
unplayed: '未被收录',
|
||||
played: '被收录',
|
||||
all: '全部'
|
||||
}
|
||||
return map[type]
|
||||
}
|
||||
|
||||
const filteredSongs = computed(() => {
|
||||
if (!songs.value) return []
|
||||
return songs.value.filter((song) => {
|
||||
if (filterType.value === 'all') return true
|
||||
if (filterType.value === 'played') return song.played
|
||||
if (filterType.value === 'unplayed') return !song.played
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
return (
|
||||
filteredSongs.value.length > 0 &&
|
||||
filteredSongs.value.every((s) => selectedSongIds.value.has(s.id))
|
||||
)
|
||||
})
|
||||
|
||||
const isSelected = (id) => selectedSongIds.value.has(id)
|
||||
|
||||
const toggleSelection = (id) => {
|
||||
if (selectedSongIds.value.has(id)) {
|
||||
selectedSongIds.value.delete(id)
|
||||
} else {
|
||||
selectedSongIds.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
// 取消全选可见项
|
||||
filteredSongs.value.forEach((s) => selectedSongIds.value.delete(s.id))
|
||||
} else {
|
||||
// 全选可见项
|
||||
filteredSongs.value.forEach((s) => selectedSongIds.value.add(s.id))
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSemesters = async () => {
|
||||
loadingSemesters.value = true
|
||||
try {
|
||||
const res = await $fetch('/api/semesters/options')
|
||||
if (res.success) {
|
||||
// 彻底过滤掉当前活跃学期,只显示往期学期供导入
|
||||
semesterList.value = res.data.filter(
|
||||
(sem) => sem.isActive === false || sem.isActive === 0 || sem.isActive === '0'
|
||||
)
|
||||
// 不自动选择,强制用户手动选择
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取学期列表失败', e)
|
||||
error.value = '加载学期列表失败'
|
||||
} finally {
|
||||
loadingSemesters.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadSongs = async () => {
|
||||
if (!selectedSemester.value) return
|
||||
|
||||
loadingSongs.value = true
|
||||
error.value = ''
|
||||
songs.value = []
|
||||
selectedSongIds.value.clear()
|
||||
|
||||
try {
|
||||
const res = await $fetch('/api/songs', {
|
||||
params: {
|
||||
semester: selectedSemester.value,
|
||||
scope: 'mine',
|
||||
bypass_cache: 'true' // 确保获取最新数据
|
||||
}
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
songs.value = res.data.songs
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取歌曲失败', e)
|
||||
error.value = '加载歌曲列表失败'
|
||||
} finally {
|
||||
loadingSongs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (selectedSongIds.value.size === 0) return
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const res = await $fetch('/api/songs/import', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
songIds: Array.from(selectedSongIds.value)
|
||||
}
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
if (res.results) {
|
||||
importResult.value = res.results
|
||||
} else {
|
||||
// 如果结果结构不同,进行降级处理
|
||||
importResult.value = {
|
||||
success: res.count,
|
||||
failed: 0,
|
||||
details: []
|
||||
}
|
||||
}
|
||||
emit('import-success')
|
||||
// 不关闭弹窗,显示结果视图
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('导入失败', e)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(e.message || '导入失败', 'error')
|
||||
}
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
importResult.value = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
importResult.value = null // 重置结果
|
||||
if (semesterList.value.length === 0) {
|
||||
fetchSemesters()
|
||||
}
|
||||
// 重置状态
|
||||
selectedSongIds.value.clear()
|
||||
filterType.value = 'unplayed'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(selectedSemester, () => {
|
||||
if (selectedSemester.value) {
|
||||
loadSongs()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<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"
|
||||
@click="handleClose"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-sm bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
|
||||
>
|
||||
<Icon name="music" :size="20" />
|
||||
</div>
|
||||
网易云扫码登录
|
||||
</h3>
|
||||
<p class="text-xs text-zinc-500 mt-1 ml-13">扫描二维码以安全登录您的账号</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||||
@click="handleClose"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主体 -->
|
||||
<div class="p-8 pt-4 flex flex-col items-center">
|
||||
<div class="w-full flex flex-col items-center min-h-[250px] justify-center">
|
||||
<div v-if="loading" class="flex flex-col items-center text-zinc-500">
|
||||
<Icon name="loader" :size="48" class="mb-4 animate-spin text-zinc-400" />
|
||||
<p class="font-bold uppercase tracking-widest text-[10px]">正在获取二维码...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="qrImg" class="relative group">
|
||||
<div
|
||||
class="p-4 bg-white rounded-3xl shadow-inner transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
>
|
||||
<img :src="qrImg" alt="Login QR Code" class="w-44 h-44 object-contain" >
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isExpired"
|
||||
class="absolute inset-0 bg-zinc-900/90 backdrop-blur-sm rounded-3xl flex items-center justify-center cursor-pointer transition-all hover:bg-zinc-900/80"
|
||||
@click="initLogin"
|
||||
>
|
||||
<div class="flex flex-col items-center text-zinc-100">
|
||||
<Icon name="refresh" :size="40" class="mb-3 text-zinc-400" />
|
||||
<span class="font-black uppercase tracking-widest text-xs">二维码已失效</span>
|
||||
<span class="text-[10px] text-zinc-500 mt-1 font-bold">点击刷新</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center h-6">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
>
|
||||
<p
|
||||
v-if="status === 800"
|
||||
class="text-zinc-400 text-xs font-black uppercase tracking-widest"
|
||||
>
|
||||
二维码已过期,请点击刷新
|
||||
</p>
|
||||
<p
|
||||
v-else-if="status === 801"
|
||||
class="text-zinc-400 text-xs font-black uppercase tracking-widest"
|
||||
>
|
||||
请使用网易云音乐APP扫码登录
|
||||
</p>
|
||||
<p
|
||||
v-else-if="status === 802"
|
||||
class="text-blue-500 text-xs font-black uppercase tracking-widest flex items-center justify-center"
|
||||
>
|
||||
<Icon name="check" :size="16" class="mr-2" />
|
||||
扫描成功,请在手机上确认
|
||||
</p>
|
||||
<p
|
||||
v-else-if="status === 803"
|
||||
class="text-emerald-500 text-xs font-black uppercase tracking-widest"
|
||||
>
|
||||
登录成功,正在跳转...
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 说明提示 -->
|
||||
<div class="mt-8 p-4 bg-zinc-800/30 rounded-2xl border border-zinc-800/50 w-full">
|
||||
<p
|
||||
class="text-[10px] leading-relaxed text-zinc-500 text-center uppercase tracking-[0.15em] font-black"
|
||||
>
|
||||
说明:登录状态将保存到您的浏览器中,用于搜索播客等功能。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'login-success', data: { cookie: string; user: any }): void
|
||||
}>()
|
||||
|
||||
const BASE_URL = '/api/api-enhanced/netease'
|
||||
|
||||
const qrImg = ref('')
|
||||
const loading = ref(false)
|
||||
const status = ref(0) // 800已过期、801待扫码、802已扫码、803登录成功
|
||||
const isExpired = ref(false)
|
||||
let timer: any = null
|
||||
let unikey = ''
|
||||
|
||||
const handleClose = () => {
|
||||
stopPolling()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const initLogin = async () => {
|
||||
stopPolling()
|
||||
loading.value = true
|
||||
isExpired.value = false
|
||||
status.value = 0
|
||||
|
||||
try {
|
||||
// 获取二维码登录密钥
|
||||
const keyRes = await fetch(`${BASE_URL}/login/qr/key?timestamp=${Date.now()}`)
|
||||
const keyData = await keyRes.json()
|
||||
unikey = keyData.data.unikey
|
||||
|
||||
// 生成二维码
|
||||
const qrRes = await fetch(
|
||||
`${BASE_URL}/login/qr/create?key=${unikey}&qrimg=true×tamp=${Date.now()}&ua=pc`
|
||||
)
|
||||
const qrData = await qrRes.json()
|
||||
qrImg.value = qrData.data.qrimg
|
||||
status.value = 801
|
||||
|
||||
// 启动轮询
|
||||
timer = setInterval(checkStatus, 3000)
|
||||
} catch (err) {
|
||||
console.error('初始化登录失败:', err)
|
||||
status.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const checkStatus = async () => {
|
||||
if (!unikey) return
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BASE_URL}/login/qr/check?key=${unikey}×tamp=${Date.now()}&ua=pc`
|
||||
)
|
||||
const data = await res.json()
|
||||
status.value = data.code
|
||||
|
||||
if (data.code === 800) {
|
||||
// 已过期
|
||||
isExpired.value = true
|
||||
stopPolling()
|
||||
} else if (data.code === 803) {
|
||||
// 登录成功
|
||||
stopPolling()
|
||||
const cookie = data.cookie
|
||||
await handleLoginSuccess(cookie)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('检查二维码状态失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoginSuccess = async (cookie: string) => {
|
||||
try {
|
||||
// 通过登录凭证获取用户信息
|
||||
const userRes = await fetch(`${BASE_URL}/login/status?timestamp=${Date.now()}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ cookie })
|
||||
})
|
||||
const res = await userRes.json()
|
||||
|
||||
const userInfo = {
|
||||
cookie,
|
||||
user: {
|
||||
userId: res.data?.profile?.userId,
|
||||
id: res.data?.profile?.userId,
|
||||
nickname: res.data?.profile?.nickname,
|
||||
avatarUrl: res.data?.profile?.avatarUrl,
|
||||
userName: res.data?.profile?.nickname
|
||||
}
|
||||
}
|
||||
|
||||
emit('login-success', userInfo)
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
console.error('获取用户信息失败:', err)
|
||||
emit('login-success', { cookie, user: null })
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听显示属性以初始化/停止
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
initLogin()
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,628 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<Transition name="scale">
|
||||
<div
|
||||
v-if="show"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-zinc-800 shrink-0">
|
||||
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">
|
||||
上传到网易云音乐
|
||||
</h3>
|
||||
<button
|
||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
|
||||
<!-- 登录状态检查 -->
|
||||
<section v-if="!isLoggedIn" class="space-y-3">
|
||||
<div class="bg-yellow-500/10 border border-yellow-500/20 rounded-2xl p-4">
|
||||
<p class="text-xs text-yellow-400 mb-3">请先登录网易云音乐账号</p>
|
||||
<button
|
||||
class="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white text-xs font-bold rounded-xl transition-colors"
|
||||
@click="showLoginModal"
|
||||
>
|
||||
立即登录
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<!-- 音质选择 -->
|
||||
<section class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase text-zinc-600 tracking-[0.2em] px-1"
|
||||
>选择音质</label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
v-for="option in qualityOptions"
|
||||
:key="option.value"
|
||||
class="flex flex-col p-4 rounded-2xl border text-left transition-all relative overflow-hidden group"
|
||||
:class="[
|
||||
selectedQuality === option.value
|
||||
? 'bg-blue-600/10 border-blue-500 shadow-sm'
|
||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
@click="selectedQuality = option.value"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1 relative z-10">
|
||||
<span
|
||||
class="text-xs font-bold transition-colors"
|
||||
:class="
|
||||
selectedQuality === option.value ? 'text-blue-400' : 'text-zinc-200'
|
||||
"
|
||||
>{{ option.label }}</span
|
||||
>
|
||||
<div
|
||||
v-if="selectedQuality === option.value"
|
||||
class="w-1.5 h-1.5 bg-blue-500 rounded-full shadow-[0_0_8px_rgba(59,130,246,0.5)]"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[10px] text-zinc-500 relative z-10">{{
|
||||
option.description
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 歌曲信息 -->
|
||||
<section class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase text-zinc-600 tracking-[0.2em] px-1"
|
||||
>歌曲信息</label
|
||||
>
|
||||
<div class="bg-zinc-950 border border-zinc-800 rounded-2xl p-4 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
v-if="song?.img || song?.cover"
|
||||
:src="song.img || song.cover"
|
||||
alt="封面"
|
||||
class="w-12 h-12 rounded-lg object-cover"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-bold text-zinc-200 truncate">{{ songName }}</p>
|
||||
<p class="text-xs text-zinc-500 truncate">{{ artistName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<section
|
||||
v-if="uploading || uploadProgress > 0"
|
||||
class="space-y-3 pt-4 border-t border-zinc-800/50"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between text-[10px] font-bold uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-zinc-400">{{ uploadStatus }}</span>
|
||||
<span class="text-blue-400">{{ uploadProgress }}%</span>
|
||||
</div>
|
||||
<div
|
||||
class="h-2 bg-zinc-950 rounded-full overflow-hidden border border-zinc-800/50 relative"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-300 ease-out relative overflow-hidden"
|
||||
:style="{ width: `${uploadProgress}%` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="uploadMessage" class="text-[10px] text-zinc-500 text-center">
|
||||
{{ uploadMessage }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="isLoggedIn" class="p-4 border-t border-zinc-800 shrink-0 flex gap-3">
|
||||
<button
|
||||
class="flex-1 py-3 px-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
|
||||
:disabled="uploading"
|
||||
@click="closeDialog"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white text-xs font-bold rounded-xl transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="uploading"
|
||||
@click="startUpload"
|
||||
>
|
||||
{{ uploading ? '上传中...' : '开始上传' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useAudioQuality, QUALITY_OPTIONS } from '~/composables/useAudioQuality'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
song: any // 来源歌曲对象
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const songName = computed(
|
||||
() => props.song?.name || props.song?.song || props.song?.title || '未知歌曲'
|
||||
)
|
||||
const artistName = computed(() => props.song?.singer || props.song?.artist || '未知歌手')
|
||||
const albumName = computed(() => props.song?.album || '未知专辑')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close' | 'upload-success' | 'show-login'): void
|
||||
}>()
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
|
||||
// 音质选项
|
||||
const qualityOptions = QUALITY_OPTIONS.tencent
|
||||
|
||||
const selectedQuality = ref(8) // 默认高音质
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const uploadStatus = ref('')
|
||||
const uploadMessage = ref('')
|
||||
const isLoggedIn = ref(false)
|
||||
|
||||
// 检查登录状态
|
||||
const checkLoginStatus = () => {
|
||||
if (import.meta.client) {
|
||||
const cookie = localStorage.getItem('netease_cookie')
|
||||
isLoggedIn.value = !!cookie
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkLoginStatus()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
checkLoginStatus()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const closeDialog = () => {
|
||||
if (!uploading.value) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
const showLoginModal = () => {
|
||||
emit('show-login')
|
||||
}
|
||||
|
||||
// 获取网易云音乐Cookie
|
||||
const getNeteaseCookie = () => {
|
||||
if (import.meta.client) {
|
||||
return localStorage.getItem('netease_cookie') || ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 获取QQ音乐下载链接
|
||||
const getQQMusicUrl = async (strMediaMid: string, quality: number): Promise<string> => {
|
||||
uploadStatus.value = '获取下载链接'
|
||||
|
||||
if (!strMediaMid) {
|
||||
throw new Error('缺少歌曲ID (strMediaMid)')
|
||||
}
|
||||
|
||||
// 使用vkeys API获取QQ音乐链接
|
||||
const apiUrl = `https://api.vkeys.cn/v2/music/tencent?id=${strMediaMid}&quality=${quality}`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取链接失败: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200 && data.data && data.data.url) {
|
||||
let url = data.data.url
|
||||
// 统一改为安全协议链接
|
||||
if (url.startsWith('http://')) {
|
||||
url = url.replace('http://', 'https://')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
throw new Error('无法获取有效的播放链接')
|
||||
}
|
||||
|
||||
// 通过文件头识别音频格式
|
||||
const detectAudioType = async (blob: Blob): Promise<string | null> => {
|
||||
const arr = new Uint8Array(await blob.slice(0, 12).arrayBuffer())
|
||||
|
||||
if (arr[0] === 0x66 && arr[1] === 0x4c && arr[2] === 0x61 && arr[3] === 0x43) {
|
||||
return 'flac'
|
||||
}
|
||||
|
||||
if (arr[0] === 0x49 && arr[1] === 0x44 && arr[2] === 0x33) {
|
||||
return 'mp3'
|
||||
}
|
||||
|
||||
if (arr[0] === 0xff && (arr[1] & 0xe0) === 0xe0) {
|
||||
return 'mp3'
|
||||
}
|
||||
|
||||
if (arr[0] === 0x4f && arr[1] === 0x67 && arr[2] === 0x67 && arr[3] === 0x53) {
|
||||
return 'ogg'
|
||||
}
|
||||
|
||||
if (
|
||||
arr[0] === 0x52 &&
|
||||
arr[1] === 0x49 &&
|
||||
arr[2] === 0x46 &&
|
||||
arr[3] === 0x46 &&
|
||||
arr[8] === 0x57 &&
|
||||
arr[9] === 0x41 &&
|
||||
arr[10] === 0x56 &&
|
||||
arr[11] === 0x45
|
||||
) {
|
||||
return 'wav'
|
||||
}
|
||||
|
||||
if (
|
||||
arr[4] === 0x66 &&
|
||||
arr[5] === 0x74 &&
|
||||
arr[6] === 0x79 &&
|
||||
arr[7] === 0x70 &&
|
||||
arr[8] === 0x4d &&
|
||||
arr[9] === 0x34 &&
|
||||
arr[10] === 0x41 &&
|
||||
arr[11] === 0x20
|
||||
) {
|
||||
return 'm4a'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 下载音频文件
|
||||
const downloadAudio = async (url: string): Promise<{ blob: Blob; ext: string }> => {
|
||||
uploadStatus.value = '正在下载音频'
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败: ${response.status}`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type')
|
||||
const contentLength = response.headers.get('content-length')
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('响应体为空')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let receivedLength = 0
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
chunks.push(value)
|
||||
receivedLength += value.length
|
||||
|
||||
if (total > 0) {
|
||||
// 下载进度占总进度的50%
|
||||
uploadProgress.value = Math.floor((receivedLength / total) * 50)
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks as BlobPart[], { type: contentType || 'audio/mpeg' })
|
||||
|
||||
// 尝试通过文件头检测真实格式
|
||||
let ext = await detectAudioType(blob)
|
||||
|
||||
if (!ext) {
|
||||
// 回退:按响应类型判断
|
||||
if (contentType) {
|
||||
switch (true) {
|
||||
case contentType.includes('audio/flac') || contentType.includes('application/x-flac'):
|
||||
ext = 'flac'
|
||||
break
|
||||
case contentType.includes('audio/wav') || contentType.includes('audio/x-wav'):
|
||||
ext = 'wav'
|
||||
break
|
||||
case contentType.includes('audio/ogg'):
|
||||
ext = 'ogg'
|
||||
break
|
||||
case contentType.includes('audio/aac') || contentType.includes('audio/mp4'):
|
||||
ext = 'm4a'
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:按链接后缀判断
|
||||
if (!ext) {
|
||||
if (url.includes('.flac')) {
|
||||
ext = 'flac'
|
||||
} else {
|
||||
ext = 'mp3' // 默认使用常规格式
|
||||
}
|
||||
}
|
||||
|
||||
uploadProgress.value = 50
|
||||
return { blob, ext }
|
||||
}
|
||||
|
||||
// 上传到网易云音乐
|
||||
const uploadToNetease = async (audioBlob: Blob, filename: string) => {
|
||||
uploadStatus.value = '正在计算文件指纹'
|
||||
const arrayBuffer = await audioBlob.arrayBuffer()
|
||||
const wordArray = CryptoJS.lib.WordArray.create(arrayBuffer)
|
||||
const md5 = CryptoJS.MD5(wordArray).toString()
|
||||
const fileSize = audioBlob.size
|
||||
const cookie = getNeteaseCookie()
|
||||
|
||||
const baseApiUrl = '/api/api-enhanced/netease'
|
||||
|
||||
// 获取文件扩展名
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || 'mp3'
|
||||
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
flac: 'audio/flac',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
m4a: 'audio/mp4',
|
||||
mp3: 'audio/mpeg'
|
||||
}
|
||||
|
||||
const contentType = contentTypeMap[ext] || 'audio/mpeg'
|
||||
|
||||
// 获取上传凭证
|
||||
uploadStatus.value = '正在获取上传凭证'
|
||||
const tokenUrl = `${baseApiUrl}/cloud/upload/token?time=${Date.now()}`
|
||||
|
||||
const tokenRes = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cookie,
|
||||
md5,
|
||||
fileSize,
|
||||
filename
|
||||
})
|
||||
})
|
||||
|
||||
const tokenData = await tokenRes.json()
|
||||
|
||||
if (tokenData.code !== 200) {
|
||||
throw new Error(`获取凭证失败: ${tokenData.msg || tokenData.code}`)
|
||||
}
|
||||
|
||||
const { needUpload, uploadUrl, uploadToken, objectKey, resourceId, songId } = tokenData.data
|
||||
|
||||
if (needUpload) {
|
||||
// 上传到对象存储
|
||||
uploadStatus.value = '正在上传到网易云音乐'
|
||||
|
||||
// 使用上传事件获取进度
|
||||
await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.floor((e.loaded / e.total) * 100)
|
||||
// 上传阶段进度映射到 50%~95%
|
||||
uploadProgress.value = 50 + Math.floor(percent * 0.45)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.responseText)
|
||||
} else {
|
||||
reject(new Error(`NOS上传失败: ${xhr.status}`))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('NOS网络错误'))
|
||||
})
|
||||
|
||||
xhr.open('POST', uploadUrl)
|
||||
xhr.setRequestHeader('x-nos-token', uploadToken)
|
||||
xhr.setRequestHeader('Content-MD5', md5)
|
||||
xhr.setRequestHeader('Content-Type', contentType)
|
||||
xhr.send(audioBlob)
|
||||
})
|
||||
} else {
|
||||
uploadProgress.value = 95
|
||||
uploadStatus.value = '文件已存在,秒传成功'
|
||||
}
|
||||
|
||||
// 完成上传并写入云盘信息
|
||||
uploadStatus.value = '正在保存云盘信息'
|
||||
|
||||
const completeUrl = `${baseApiUrl}/cloud/upload/complete?time=${Date.now()}`
|
||||
const completeRes = await fetch(completeUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cookie,
|
||||
md5,
|
||||
songId,
|
||||
resourceId,
|
||||
filename,
|
||||
song: songName.value,
|
||||
artist: artistName.value,
|
||||
album: albumName.value,
|
||||
bitrate: 999000
|
||||
})
|
||||
})
|
||||
|
||||
const completeData = await completeRes.json()
|
||||
|
||||
if (completeData.code !== 200) {
|
||||
if (completeData.code === 502 || completeData.code === 526) {
|
||||
console.warn(`完成接口返回 ${completeData.code},可能为暂时性错误`, completeData)
|
||||
throw new Error(
|
||||
`云盘信息保存可能失败 (错误码: ${completeData.code}),请检查云盘内是否成功上传歌曲。`
|
||||
)
|
||||
} else {
|
||||
throw new Error(`发布失败: ${completeData.msg || completeData.code}`)
|
||||
}
|
||||
}
|
||||
|
||||
uploadProgress.value = 100
|
||||
return completeData
|
||||
}
|
||||
|
||||
// 开始上传流程
|
||||
const startUpload = async () => {
|
||||
if (uploading.value) return
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
uploadStatus.value = '准备中'
|
||||
uploadMessage.value = ''
|
||||
|
||||
try {
|
||||
// 兼容不同来源的歌曲标识字段
|
||||
const musicId =
|
||||
props.song.strMediaMid ||
|
||||
props.song.songmid ||
|
||||
props.song.songId ||
|
||||
props.song.musicId ||
|
||||
props.song.id ||
|
||||
props.song.mid
|
||||
|
||||
if (!musicId) {
|
||||
console.error('所有可能的ID字段都为空')
|
||||
throw new Error('无法获取歌曲ID,请重试')
|
||||
}
|
||||
|
||||
// 获取来源歌曲下载链接
|
||||
uploadMessage.value = '正在从QQ音乐获取音频链接...'
|
||||
|
||||
const musicUrl = await getQQMusicUrl(musicId, selectedQuality.value)
|
||||
|
||||
if (!musicUrl) {
|
||||
throw new Error('无法获取音乐播放链接')
|
||||
}
|
||||
|
||||
// 下载音频文件
|
||||
uploadMessage.value = '正在下载音频文件...'
|
||||
const { blob: audioBlob, ext } = await downloadAudio(musicUrl)
|
||||
|
||||
// 上传到网易云音乐云盘
|
||||
uploadMessage.value = '正在上传到网易云音乐云盘...'
|
||||
const filename = `${artistName.value} - ${songName.value}.${ext}`
|
||||
await uploadToNetease(audioBlob, filename)
|
||||
|
||||
uploadProgress.value = 100
|
||||
uploadStatus.value = '上传完成'
|
||||
uploadMessage.value = '歌曲已成功上传到您的网易云音乐云盘'
|
||||
|
||||
showSuccess('歌曲已成功上传到网易云音乐云盘')
|
||||
|
||||
emit('upload-success')
|
||||
|
||||
// 成功后延迟关闭对话框
|
||||
setTimeout(() => {
|
||||
closeDialog()
|
||||
}, 1500)
|
||||
} catch (error: any) {
|
||||
console.error('上传失败:', error)
|
||||
uploadStatus.value = '上传失败'
|
||||
uploadMessage.value = error.message || '未知错误'
|
||||
|
||||
showError(`上传失败: ${error.message}`)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scale-enter-active,
|
||||
.scale-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.scale-enter-from,
|
||||
.scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,583 @@
|
||||
<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-black/60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-2xl bg-zinc-900 border border-zinc-800 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">
|
||||
<button
|
||||
v-if="view === 'songs'"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 transition-all"
|
||||
@click="backToPlaylists"
|
||||
>
|
||||
<Icon name="arrow-left" :size="20" />
|
||||
</button>
|
||||
<h3
|
||||
class="text-xl font-black text-zinc-100 tracking-tight truncate max-w-[300px] sm:max-w-md"
|
||||
>
|
||||
{{ view === 'playlists' ? '选择歌单' : selectedPlaylist?.name || '歌单详情' }}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 transition-all"
|
||||
@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="loading"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-500"
|
||||
>
|
||||
<Icon name="loader" :size="48" class="mb-4 animate-spin text-zinc-400" />
|
||||
<p class="font-medium">处理中...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex flex-col items-center justify-center py-20 text-center px-8"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="alert-circle" :size="32" class="text-red-500" />
|
||||
</div>
|
||||
<p class="text-zinc-400 font-medium mb-6">{{ error }}</p>
|
||||
<button
|
||||
class="px-8 py-3 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-black transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click="retry"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 歌单列表视图 -->
|
||||
<div v-else-if="view === 'playlists'" class="playlist-list space-y-3">
|
||||
<div
|
||||
v-if="playlists.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-zinc-500"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-zinc-800/50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="music" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="font-medium">暂无歌单</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="playlist in playlists"
|
||||
v-else
|
||||
:key="playlist.id"
|
||||
class="group flex items-center p-4 bg-zinc-800/30 border border-zinc-800/50 rounded-3xl hover:bg-zinc-800/50 hover:border-zinc-700 transition-all cursor-pointer"
|
||||
@click="selectPlaylist(playlist)"
|
||||
>
|
||||
<div class="w-16 h-16 rounded-2xl overflow-hidden bg-zinc-800 mr-4 flex-shrink-0">
|
||||
<img
|
||||
:src="convertToHttps(playlist.coverImgUrl)"
|
||||
alt="cover"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-zinc-100 truncate group-hover:text-white transition-colors"
|
||||
>
|
||||
{{ playlist.name }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-3 mt-1 text-xs text-zinc-500">
|
||||
<span class="flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
|
||||
{{ playlist.trackCount }}首
|
||||
</span>
|
||||
<span class="flex items-center truncate">
|
||||
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
|
||||
by {{ playlist.creator?.nickname }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 text-zinc-600 group-hover:text-zinc-400 transition-colors">
|
||||
<Icon name="chevron-right" :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表视图 -->
|
||||
<div v-else class="song-list space-y-3">
|
||||
<div
|
||||
v-if="songs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-zinc-500"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-zinc-800/50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="music" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="font-medium">歌单为空</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="song in songs"
|
||||
v-else
|
||||
:key="song.id"
|
||||
class="group flex items-center p-4 bg-zinc-800/30 border border-zinc-800/50 rounded-3xl hover:bg-zinc-800/50 hover:border-zinc-700 transition-all"
|
||||
>
|
||||
<!-- 封面与播放叠加层 -->
|
||||
<div
|
||||
class="relative w-14 h-14 rounded-2xl overflow-hidden bg-zinc-800 mr-4 flex-shrink-0 group/cover cursor-pointer"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<img
|
||||
:src="convertToHttps(song.al?.picUrl)"
|
||||
alt="cover"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover/cover:opacity-100 transition-opacity"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center"
|
||||
>
|
||||
<Icon name="play" :size="16" class="text-white fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-zinc-100 truncate group-hover:text-white transition-colors"
|
||||
>
|
||||
{{ song.name }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2 mt-0.5 text-xs text-zinc-500 truncate">
|
||||
<span class="truncate">{{ song.ar?.map((a) => a.name).join('/') }}</span>
|
||||
<span v-if="song.al?.name" class="opacity-40 shrink-0">·</span>
|
||||
<span v-if="song.al?.name" class="truncate opacity-60">{{ song.al.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="ml-4 shrink-0 flex items-center gap-3">
|
||||
<div
|
||||
v-if="songsLoadingForSimilar"
|
||||
class="text-xs font-bold text-zinc-600 animate-pulse"
|
||||
>
|
||||
处理中...
|
||||
</div>
|
||||
<div v-else-if="getSimilarSong(song)" class="flex flex-col items-end gap-1.5">
|
||||
<span
|
||||
v-if="getSimilarSong(song)?.played"
|
||||
class="px-2 py-0.5 rounded-md bg-red-500/10 text-red-500 text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
已播放
|
||||
</span>
|
||||
<span
|
||||
v-else-if="getSimilarSong(song)?.scheduled"
|
||||
class="px-2 py-0.5 rounded-md bg-amber-500/10 text-amber-500 text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
已排期
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-0.5 rounded-md bg-zinc-700/50 text-zinc-500 text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
已存在
|
||||
</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="getSimilarSong(song)?.played && isSuperAdmin"
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click.stop="selectSong(song)"
|
||||
>
|
||||
{{ submitting && selectedSongId === song.id ? '...' : '继续投稿' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-3 py-1.5 rounded-xl text-[10px] font-black transition-all active:scale-95 disabled:cursor-not-allowed uppercase tracking-widest"
|
||||
:class="[
|
||||
getSimilarSong(song)?.voted
|
||||
? 'bg-red-500/10 text-red-500 border border-red-500/20'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-100 border border-zinc-700/50 hover:border-zinc-600'
|
||||
]"
|
||||
:disabled="
|
||||
getSimilarSong(song)?.played ||
|
||||
getSimilarSong(song)?.scheduled ||
|
||||
getSimilarSong(song)?.voted ||
|
||||
submitting
|
||||
"
|
||||
@click.stop="handleLike(getSimilarSong(song))"
|
||||
>
|
||||
{{ getSimilarSong(song)?.voted ? '已点赞' : '点赞' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
:disabled="submitting || songsLoadingForSimilar"
|
||||
class="px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-blue-900/20"
|
||||
@click.stop="selectSong(song)"
|
||||
>
|
||||
{{ submitting && selectedSongId === song.id ? '提交中...' : '选择投稿' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多按钮 -->
|
||||
<div v-if="hasMore" class="pt-6 pb-2 flex justify-center">
|
||||
<button
|
||||
:disabled="moreLoading"
|
||||
class="px-8 py-3 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black disabled:opacity-50 transition-all flex items-center gap-2 uppercase tracking-widest"
|
||||
@click="loadMore"
|
||||
>
|
||||
<Icon v-if="moreLoading" name="loader" :size="16" class="animate-spin" />
|
||||
{{ moreLoading ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getPlaylistTracks, getUserPlaylists } from '~/utils/neteaseApi'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import Icon from '../UI/Icon.vue'
|
||||
import { useSongs } from '~/composables/useSongs'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useSemesters } from '~/composables/useSemesters'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
cookie: String,
|
||||
uid: [String, Number]
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'submit', 'play'])
|
||||
|
||||
const view = ref('playlists') // 'playlists' | 'songs'
|
||||
const playlists = ref([])
|
||||
const songs = ref([])
|
||||
const selectedPlaylist = ref(null)
|
||||
const loading = ref(false)
|
||||
const songsLoadingForSimilar = ref(false)
|
||||
const error = ref('')
|
||||
const submitting = ref(false)
|
||||
const selectedSongId = ref(null)
|
||||
|
||||
// 分页状态
|
||||
const limit = ref(100)
|
||||
const offset = ref(0)
|
||||
const hasMore = ref(true)
|
||||
const moreLoading = ref(false)
|
||||
|
||||
const songService = useSongs()
|
||||
const auth = useAuth()
|
||||
const { currentSemester, fetchCurrentSemester } = useSemesters()
|
||||
const isSuperAdmin = computed(() => auth.user.value?.role === 'SUPER_ADMIN')
|
||||
|
||||
// 标准化字符串
|
||||
const normalizeString = (str) => {
|
||||
if (!str) return ''
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉""''""''、,。!?:;~·]/g, '')
|
||||
.replace(/[&&]/g, 'and')
|
||||
.replace(/[feat\.?|ft\.?]/gi, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// 检查是否已存在相似歌曲
|
||||
const getSimilarSong = (songData) => {
|
||||
if (!songData) return null
|
||||
|
||||
const title = songData.name
|
||||
const artist = songData.ar?.map((a) => a.name).join('/')
|
||||
|
||||
if (!title || !artist) return null
|
||||
|
||||
const normalizedTitle = normalizeString(title)
|
||||
const normalizedArtist = normalizeString(artist)
|
||||
const currentSemesterName = currentSemester.value?.name
|
||||
|
||||
// 如果没有获取到当前学期信息,暂时不进行检查,避免误报
|
||||
if (!currentSemesterName) return null
|
||||
|
||||
return songService.songs.value.find((song) => {
|
||||
const songTitle = normalizeString(song.title)
|
||||
const songArtist = normalizeString(song.artist)
|
||||
const titleMatch = songTitle === normalizedTitle && songArtist === normalizedArtist
|
||||
|
||||
// 必须匹配当前学期
|
||||
return titleMatch && song.semester === currentSemesterName
|
||||
})
|
||||
}
|
||||
|
||||
const fetchUserPlaylists = async () => {
|
||||
if (!props.cookie || !props.uid) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const { code, body, message } = await getUserPlaylists(props.uid, props.cookie)
|
||||
if (code === 200 && body && body.playlist) {
|
||||
playlists.value = body.playlist
|
||||
} else {
|
||||
error.value = message || '获取歌单列表失败'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = '网络请求失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPlaylistSongs = async (playlistId, isLoadMore = false) => {
|
||||
if (!props.cookie) return
|
||||
|
||||
if (isLoadMore) {
|
||||
moreLoading.value = true
|
||||
} else {
|
||||
loading.value = true
|
||||
songs.value = []
|
||||
offset.value = 0
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 当前偏移逻辑:
|
||||
// 首次加载: offset=0, limit=100. 下次加载: offset=100, limit=100
|
||||
// 注意: API 偏移参数如果遵循文档逻辑,应作为页面偏移?
|
||||
// "传递 limit=50&offset=0 得到 1-50。传递 limit=50&offset=50 得到 51-100"
|
||||
// 所以 offset 变量应该跟踪目前为止加载的歌曲数量。
|
||||
|
||||
const { code, body, message } = await getPlaylistTracks(
|
||||
playlistId,
|
||||
limit.value,
|
||||
offset.value,
|
||||
props.cookie
|
||||
)
|
||||
if (code === 200 && body && body.songs) {
|
||||
if (isLoadMore) {
|
||||
songs.value = [...songs.value, ...body.songs]
|
||||
} else {
|
||||
songs.value = body.songs
|
||||
}
|
||||
|
||||
// 为下次调用更新偏移
|
||||
offset.value += body.songs.length
|
||||
|
||||
// 检查是否还有更多歌曲
|
||||
if (body.songs.length < limit.value) {
|
||||
hasMore.value = false
|
||||
}
|
||||
} else {
|
||||
error.value = message || '获取歌单歌曲失败'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = '网络请求失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
moreLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (selectedPlaylist.value) {
|
||||
fetchPlaylistSongs(selectedPlaylist.value.id, true)
|
||||
}
|
||||
}
|
||||
|
||||
const selectPlaylist = (playlist) => {
|
||||
selectedPlaylist.value = playlist
|
||||
view.value = 'songs'
|
||||
fetchPlaylistSongs(playlist.id)
|
||||
}
|
||||
|
||||
const backToPlaylists = () => {
|
||||
view.value = 'playlists'
|
||||
selectedPlaylist.value = null
|
||||
songs.value = []
|
||||
offset.value = 0
|
||||
hasMore.value = true
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const retry = () => {
|
||||
if (view.value === 'playlists') {
|
||||
fetchUserPlaylists()
|
||||
} else if (selectedPlaylist.value) {
|
||||
// 重新从头加载歌曲
|
||||
fetchPlaylistSongs(selectedPlaylist.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (newVal) => {
|
||||
if (newVal) {
|
||||
view.value = 'playlists'
|
||||
playlists.value = []
|
||||
songs.value = []
|
||||
selectedPlaylist.value = null
|
||||
offset.value = 0
|
||||
hasMore.value = true
|
||||
|
||||
// 确保当前学期已加载,用于正确检查歌曲状态
|
||||
if (!currentSemester.value) {
|
||||
await fetchCurrentSemester()
|
||||
}
|
||||
|
||||
fetchUserPlaylists()
|
||||
|
||||
// 加载歌曲列表以便检查相似歌曲
|
||||
if (
|
||||
auth.isAuthenticated.value &&
|
||||
(!songService.songs.value || songService.songs.value.length === 0)
|
||||
) {
|
||||
songsLoadingForSimilar.value = true
|
||||
const currentSemesterName = currentSemester.value?.name
|
||||
songService
|
||||
.fetchSongs(true, currentSemesterName)
|
||||
.catch((err) => {
|
||||
console.error('加载歌曲列表失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
songsLoadingForSimilar.value = false
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 重置状态
|
||||
submitting.value = false
|
||||
selectedSongId.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.uid,
|
||||
(newVal) => {
|
||||
if (props.show && newVal) {
|
||||
fetchUserPlaylists()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleLike = async (song) => {
|
||||
if (!song || song.voted) return
|
||||
|
||||
if (song.played || song.scheduled) {
|
||||
if (window.$showNotification) {
|
||||
const message = song.played ? '已播放的歌曲不能点赞' : '已排期的歌曲不能点赞'
|
||||
window.$showNotification(message, 'warning')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await songService.voteSong(song.id)
|
||||
} catch (error) {
|
||||
console.error('点赞失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const playSong = (songData) => {
|
||||
emit('play', {
|
||||
id: songData.id,
|
||||
title: songData.name,
|
||||
artist: songData.ar?.map((a) => a.name).join('/'),
|
||||
cover: songData.al?.picUrl,
|
||||
musicPlatform: 'netease',
|
||||
musicId: songData.id.toString(),
|
||||
sourceInfo: {
|
||||
source: 'netease-backup',
|
||||
type: 'song'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectSong = (songData) => {
|
||||
selectedSongId.value = songData.id
|
||||
submitting.value = true
|
||||
|
||||
const song = {
|
||||
id: songData.id,
|
||||
title: songData.name,
|
||||
artist: songData.ar?.map((a) => a.name).join('/'),
|
||||
cover: songData.al?.picUrl,
|
||||
album: songData.al?.name,
|
||||
duration: songData.dt,
|
||||
musicPlatform: 'netease',
|
||||
musicId: songData.id.toString(),
|
||||
sourceInfo: {
|
||||
source: 'netease-backup',
|
||||
type: 'song'
|
||||
}
|
||||
}
|
||||
|
||||
emit('submit', song)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetSubmissionState: () => {
|
||||
submitting.value = false
|
||||
selectedSongId.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,486 @@
|
||||
<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-black/60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-2xl bg-zinc-900 border border-zinc-800 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 bg-blue-600/10 flex items-center justify-center text-blue-500 flex-shrink-0"
|
||||
>
|
||||
<Icon name="mic" :size="24" />
|
||||
</div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight truncate">
|
||||
{{ radioName }} - 节目列表
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 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="loading"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-500"
|
||||
>
|
||||
<Icon name="refresh" :size="48" class="animate-spin mb-4 text-blue-500" />
|
||||
<p class="font-black uppercase tracking-widest text-[10px]">加载节目中...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex flex-col items-center justify-center py-20 text-center px-8"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center text-red-500 mb-4"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="32" />
|
||||
</div>
|
||||
<p class="text-sm text-zinc-400 mb-6">{{ error }}</p>
|
||||
<button
|
||||
class="px-8 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="fetchPrograms(false)"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="programs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-zinc-500"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-zinc-800/50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="mic" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">暂无节目</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="program-list space-y-3">
|
||||
<div
|
||||
v-for="program in programs"
|
||||
:key="program.id"
|
||||
class="group flex items-center p-4 bg-zinc-800/30 border border-zinc-800/50 rounded-3xl hover:bg-zinc-800/50 hover:border-zinc-700 transition-all"
|
||||
>
|
||||
<!-- 封面与播放叠加层 -->
|
||||
<div
|
||||
class="relative w-14 h-14 rounded-2xl overflow-hidden bg-zinc-800 mr-4 flex-shrink-0 group/cover cursor-pointer"
|
||||
@click.stop="playProgram(program)"
|
||||
>
|
||||
<img
|
||||
:src="convertToHttps(program.coverUrl || program.mainSong?.album?.picUrl)"
|
||||
alt="cover"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover/cover:opacity-100 transition-opacity"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center"
|
||||
>
|
||||
<Icon name="play" :size="16" class="text-white fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 节目信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-zinc-100 truncate group-hover:text-white transition-colors"
|
||||
>
|
||||
{{ program.name }}
|
||||
</h4>
|
||||
<div
|
||||
class="flex items-center gap-3 mt-1 text-[10px] text-zinc-500 font-bold uppercase tracking-wider"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{{ formatDate(program.createTime) }}
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
|
||||
{{ formatDuration(program.duration) }}
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
|
||||
🎧 {{ formatCount(program.listenerCount) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="ml-4 shrink-0 flex items-center gap-3">
|
||||
<div
|
||||
v-if="songsLoadingForSimilar"
|
||||
class="text-[10px] font-black text-zinc-600 animate-pulse uppercase tracking-widest"
|
||||
>
|
||||
处理中...
|
||||
</div>
|
||||
<div v-else-if="getSimilarSong(program)" class="flex flex-col items-end gap-1.5">
|
||||
<span
|
||||
v-if="getSimilarSong(program)?.played"
|
||||
class="px-2 py-0.5 rounded-md bg-red-500/10 text-red-500 text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
已播放
|
||||
</span>
|
||||
<span
|
||||
v-else-if="getSimilarSong(program)?.scheduled"
|
||||
class="px-2 py-0.5 rounded-md bg-amber-500/10 text-amber-500 text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
已排期
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-0.5 rounded-md bg-zinc-700/50 text-zinc-500 text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
已存在
|
||||
</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="getSimilarSong(program)?.played && isSuperAdmin"
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click.stop="selectProgram(program)"
|
||||
>
|
||||
{{ submitting && selectedProgramId === program.id ? '...' : '继续投稿' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-3 py-1.5 rounded-xl text-[10px] font-black transition-all active:scale-95 disabled:cursor-not-allowed uppercase tracking-widest"
|
||||
:class="[
|
||||
getSimilarSong(program)?.voted
|
||||
? 'bg-red-500/10 text-red-500 border border-red-500/20'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-100 border border-zinc-700/50 hover:border-zinc-600'
|
||||
]"
|
||||
:disabled="
|
||||
getSimilarSong(program)?.played ||
|
||||
getSimilarSong(program)?.scheduled ||
|
||||
getSimilarSong(program)?.voted ||
|
||||
submitting
|
||||
"
|
||||
@click.stop="handleLikeFromProgram(getSimilarSong(program))"
|
||||
>
|
||||
{{ getSimilarSong(program)?.voted ? '已点赞' : '点赞' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
:disabled="submitting"
|
||||
class="px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-blue-900/20"
|
||||
@click="selectProgram(program)"
|
||||
>
|
||||
{{ submitting && selectedProgramId === program.id ? '提交中...' : '选择投稿' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasMore" class="pt-6 pb-2 flex justify-center">
|
||||
<button
|
||||
:disabled="loadingMore"
|
||||
class="px-8 py-3 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black disabled:opacity-50 transition-all flex items-center gap-2 uppercase tracking-widest"
|
||||
@click="loadMore"
|
||||
>
|
||||
<Icon v-if="loadingMore" name="loader" :size="16" class="animate-spin" />
|
||||
{{ loadingMore ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMusicSources } from '~/composables/useMusicSources'
|
||||
import { useSongs } from '~/composables/useSongs'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useSemesters } from '~/composables/useSemesters'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
radioId: [String, Number],
|
||||
radioName: String,
|
||||
cookie: String
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'submit', 'play'])
|
||||
|
||||
const musicSources = useMusicSources()
|
||||
const songService = useSongs()
|
||||
const auth = useAuth()
|
||||
const isSuperAdmin = computed(() => auth.user.value?.role === 'SUPER_ADMIN')
|
||||
const { currentSemester, fetchCurrentSemester } = useSemesters()
|
||||
|
||||
const programs = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const hasMore = ref(false)
|
||||
const offset = ref(0)
|
||||
const loadingMore = ref(false)
|
||||
const submitting = ref(false)
|
||||
const selectedProgramId = ref(null)
|
||||
const songsLoadingForSimilar = ref(false)
|
||||
|
||||
// 标准化字符串
|
||||
const normalizeString = (str) => {
|
||||
if (!str) return ''
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉""''""''、,。!?:;~·]/g, '')
|
||||
.replace(/[&&]/g, 'and')
|
||||
.replace(/[feat\.?|ft\.?]/gi, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// 检查是否已存在相似歌曲
|
||||
const getSimilarSong = (program) => {
|
||||
if (!program) return null
|
||||
|
||||
const tempSong = createSongObject(program)
|
||||
const title = tempSong.title
|
||||
const artist = tempSong.artist
|
||||
|
||||
if (!title || !artist) return null
|
||||
|
||||
const normalizedTitle = normalizeString(title)
|
||||
const normalizedArtist = normalizeString(artist)
|
||||
const currentSemesterName = currentSemester.value?.name
|
||||
|
||||
// 如果没有获取到当前学期信息,暂时不进行检查,避免误报
|
||||
if (!currentSemesterName) return null
|
||||
|
||||
return songService.songs.value.find((song) => {
|
||||
const songTitle = normalizeString(song.title)
|
||||
const songArtist = normalizeString(song.artist)
|
||||
const titleMatch = songTitle === normalizedTitle && songArtist === normalizedArtist
|
||||
|
||||
// 必须匹配当前学期
|
||||
return titleMatch && song.semester === currentSemesterName
|
||||
})
|
||||
}
|
||||
|
||||
const fetchPrograms = async (isLoadMore = false) => {
|
||||
if (!props.radioId) return
|
||||
|
||||
if (isLoadMore) {
|
||||
loadingMore.value = true
|
||||
} else {
|
||||
loading.value = true
|
||||
programs.value = []
|
||||
offset.value = 0
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const result = await musicSources.getDjPrograms(props.radioId, 20, offset.value, props.cookie)
|
||||
|
||||
if (result.success) {
|
||||
if (isLoadMore) {
|
||||
programs.value = [...programs.value, ...result.programs]
|
||||
} else {
|
||||
programs.value = result.programs
|
||||
}
|
||||
|
||||
hasMore.value = result.more
|
||||
offset.value += result.programs.length
|
||||
} else {
|
||||
error.value = result.error || '获取节目失败'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = '网络请求失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
fetchPrograms(true)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
// 重置状态
|
||||
submitting.value = false
|
||||
selectedProgramId.value = null
|
||||
}
|
||||
|
||||
const createSongObject = (program) => {
|
||||
// 构造提交数据
|
||||
// 使用 mainSong.id 作为唯一标识,因为 getSongUrl 需要它
|
||||
const songId = program.mainSong?.id || program.mainTrackId || program.id
|
||||
|
||||
return {
|
||||
id: songId,
|
||||
title: program.name,
|
||||
artist: program.dj?.nickname || '未知主播',
|
||||
cover: program.coverUrl || program.cover,
|
||||
album: props.radioName, // 使用电台名作为专辑名
|
||||
duration: program.duration,
|
||||
musicPlatform: 'netease-podcast',
|
||||
musicId: songId.toString(),
|
||||
sourceInfo: {
|
||||
source: 'netease-backup',
|
||||
originalId: songId.toString(),
|
||||
fetchedAt: new Date(),
|
||||
type: 'voice',
|
||||
programId: program.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectProgram = async (program) => {
|
||||
selectedProgramId.value = program.id
|
||||
submitting.value = true
|
||||
|
||||
const song = createSongObject(program)
|
||||
|
||||
emit('submit', song)
|
||||
// 不立即关闭,等待父组件处理
|
||||
// 父组件处理成功后应该关闭此弹窗
|
||||
}
|
||||
|
||||
const handleLikeFromProgram = async (song) => {
|
||||
if (!song || song.voted) {
|
||||
return
|
||||
}
|
||||
|
||||
if (song.played || song.scheduled) {
|
||||
if (window.$showNotification) {
|
||||
const message = song.played ? '已播放的歌曲不能点赞' : '已排期的歌曲不能点赞'
|
||||
window.$showNotification(message, 'warning')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await songService.voteSong(song.id)
|
||||
} catch (error) {
|
||||
console.error('点赞失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const playProgram = (program) => {
|
||||
const song = createSongObject(program)
|
||||
// 添加播放需要的额外字段
|
||||
song.musicUrl = '' // 将由 RequestForm 处理获取
|
||||
emit('play', song)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (val) => {
|
||||
if (val) {
|
||||
// 确保当前学期已加载,用于正确检查歌曲状态
|
||||
if (!currentSemester.value) {
|
||||
await fetchCurrentSemester()
|
||||
}
|
||||
|
||||
submitting.value = false
|
||||
selectedProgramId.value = null
|
||||
if (
|
||||
auth.isAuthenticated.value &&
|
||||
(!songService.songs.value || songService.songs.value.length === 0)
|
||||
) {
|
||||
const currentSemesterName = currentSemester.value?.name
|
||||
songsLoadingForSimilar.value = true
|
||||
songService
|
||||
.fetchSongs(true, currentSemesterName)
|
||||
.catch((err) => {
|
||||
console.error('加载歌曲列表失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
songsLoadingForSimilar.value = false
|
||||
})
|
||||
} else {
|
||||
songsLoadingForSimilar.value = false
|
||||
}
|
||||
fetchPrograms()
|
||||
} else {
|
||||
programs.value = []
|
||||
offset.value = 0
|
||||
submitting.value = false
|
||||
selectedProgramId.value = null
|
||||
songsLoadingForSimilar.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const formatDate = (timestamp) => {
|
||||
if (!timestamp) return ''
|
||||
return new Date(timestamp).toLocaleDateString()
|
||||
}
|
||||
|
||||
const formatDuration = (ms) => {
|
||||
if (!ms) return '00:00'
|
||||
const minutes = Math.floor(ms / 60000)
|
||||
const seconds = Math.floor((ms % 60000) / 1000)
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatCount = (count) => {
|
||||
if (!count) return '0'
|
||||
if (count > 10000) {
|
||||
return (count / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetSubmissionState: () => {
|
||||
submitting.value = false
|
||||
selectedProgramId.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,451 @@
|
||||
<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-[2000] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
@click.self="close"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900 border border-zinc-800 w-full max-w-2xl rounded-3xl overflow-hidden shadow-2xl flex flex-col max-h-[85vh]"
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
|
||||
>
|
||||
<Icon name="history" :size="20" />
|
||||
</div>
|
||||
最近播放 - 歌曲
|
||||
</h3>
|
||||
<p class="text-xs text-zinc-500 mt-1 ml-13">查看并快速投稿您最近在网易云播放的歌曲</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主体 -->
|
||||
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-500"
|
||||
>
|
||||
<Icon name="refresh" :size="32" class="animate-spin mb-4 text-blue-500" />
|
||||
<div class="text-[10px] font-black uppercase tracking-widest">
|
||||
正在获取播放记录...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex flex-col items-center justify-center py-20 text-center px-8"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center text-red-500 mb-4"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="32" />
|
||||
</div>
|
||||
<p class="text-sm text-zinc-400 mb-6">{{ error }}</p>
|
||||
<button
|
||||
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="fetchRecentSongs"
|
||||
>
|
||||
重试加载
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="songs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-600"
|
||||
>
|
||||
<Icon name="music" :size="48" class="mb-4 opacity-20" />
|
||||
<p class="text-sm font-bold uppercase tracking-widest">暂无最近播放记录</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="recent-song-list space-y-2">
|
||||
<div
|
||||
v-for="item in songs"
|
||||
:key="item.resourceId"
|
||||
class="group flex items-center gap-4 p-3 rounded-2xl hover:bg-zinc-800/50 transition-all border border-transparent hover:border-zinc-800"
|
||||
>
|
||||
<div
|
||||
class="relative w-14 h-14 rounded-xl overflow-hidden bg-zinc-800 flex-shrink-0 shadow-lg group-hover:scale-105 transition-transform duration-300"
|
||||
>
|
||||
<img
|
||||
:src="convertToHttps(item.data?.al?.picUrl)"
|
||||
alt="cover"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
@click.stop="playSong(item.data)"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center text-white border border-white/30"
|
||||
>
|
||||
<Icon name="play" :size="14" class="fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
:title="item.data?.name"
|
||||
class="text-sm font-black text-zinc-100 truncate mb-1"
|
||||
>
|
||||
{{ item.data?.name }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[10px] text-zinc-500 font-medium whitespace-nowrap">{{
|
||||
formatTime(item.playTime)
|
||||
}}</span>
|
||||
<span
|
||||
class="text-[10px] text-zinc-600 truncate font-bold uppercase tracking-tighter"
|
||||
>{{ item.data?.ar?.map((a) => a.name).join('/') }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<div
|
||||
v-if="songsLoadingForSimilar"
|
||||
class="flex items-center gap-2 px-3 py-1 bg-zinc-800/50 rounded-lg"
|
||||
>
|
||||
<Icon name="refresh" :size="10" class="animate-spin text-zinc-500" />
|
||||
<span class="text-[10px] text-zinc-500 font-black uppercase">检查中</span>
|
||||
</div>
|
||||
<template v-else-if="getSimilarSong(item.data)">
|
||||
<div
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-tighter border',
|
||||
getSimilarSong(item.data)?.played
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: getSimilarSong(item.data)?.scheduled
|
||||
? 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
: 'bg-zinc-800 text-zinc-500 border-zinc-700'
|
||||
]"
|
||||
>
|
||||
{{
|
||||
getSimilarSong(item.data)?.played
|
||||
? '已播放'
|
||||
: getSimilarSong(item.data)?.scheduled
|
||||
? '已排期'
|
||||
: '已在列表中'
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="getSimilarSong(item.data)?.played && isSuperAdmin"
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black rounded-lg transition-all uppercase tracking-widest disabled:opacity-50"
|
||||
@click="selectSong(item.data)"
|
||||
>
|
||||
{{
|
||||
submitting && selectedSongId === item.data?.id ? '处理中...' : '继续投稿'
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:disabled="
|
||||
getSimilarSong(item.data)?.played ||
|
||||
getSimilarSong(item.data)?.scheduled ||
|
||||
getSimilarSong(item.data)?.voted ||
|
||||
submitting
|
||||
"
|
||||
class="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 disabled:bg-zinc-900/50 text-zinc-400 disabled:text-zinc-700 text-[10px] font-black rounded-lg transition-all uppercase tracking-widest flex items-center gap-1.5"
|
||||
@click="
|
||||
getSimilarSong(item.data)?.played || getSimilarSong(item.data)?.scheduled
|
||||
? null
|
||||
: handleLike(getSimilarSong(item.data))
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
name="heart"
|
||||
:size="10"
|
||||
:class="[
|
||||
getSimilarSong(item.data)?.voted ? 'text-red-500 fill-current' : ''
|
||||
]"
|
||||
/>
|
||||
{{ getSimilarSong(item.data)?.voted ? '已点赞' : '点赞' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
:disabled="submitting || songsLoadingForSimilar"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black rounded-xl transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50 shadow-lg shadow-blue-900/20"
|
||||
@click="selectSong(item.data)"
|
||||
>
|
||||
{{
|
||||
submitting && selectedSongId === item.data?.id ? '正在提交...' : '选择投稿'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部栏 -->
|
||||
<div
|
||||
class="p-6 border-t border-zinc-800/50 bg-zinc-900/50 flex items-center justify-between"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>
|
||||
<Icon name="info" :size="12" />
|
||||
数据来自网易云音乐播放记录
|
||||
</div>
|
||||
<button
|
||||
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="close"
|
||||
>
|
||||
关闭窗口
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getRecentSongs } from '~/utils/neteaseApi'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useSongs } from '~/composables/useSongs'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useSemesters } from '~/composables/useSemesters'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
cookie: String
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'submit', 'play'])
|
||||
|
||||
const songs = ref([])
|
||||
const loading = ref(false)
|
||||
const songsLoadingForSimilar = ref(false)
|
||||
const error = ref('')
|
||||
const submitting = ref(false)
|
||||
const selectedSongId = ref(null)
|
||||
|
||||
const songService = useSongs()
|
||||
const auth = useAuth()
|
||||
const { currentSemester, fetchCurrentSemester } = useSemesters()
|
||||
const isSuperAdmin = computed(() => auth.user.value?.role === 'SUPER_ADMIN')
|
||||
|
||||
// 标准化字符串
|
||||
const normalizeString = (str) => {
|
||||
if (!str) return ''
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉""''""''、,。!?:;~·]/g, '')
|
||||
.replace(/[&&]/g, 'and')
|
||||
.replace(/[feat\.?|ft\.?]/gi, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// 检查是否已存在相似歌曲
|
||||
const getSimilarSong = (songData) => {
|
||||
if (!songData) return null
|
||||
|
||||
const title = songData.name
|
||||
const artist = songData.ar?.map((a) => a.name).join('/')
|
||||
|
||||
if (!title || !artist) return null
|
||||
|
||||
const normalizedTitle = normalizeString(title)
|
||||
const normalizedArtist = normalizeString(artist)
|
||||
const currentSemesterName = currentSemester.value?.name
|
||||
|
||||
// 如果没有获取到当前学期信息,暂时不进行检查,避免误报
|
||||
if (!currentSemesterName) return null
|
||||
|
||||
return songService.songs.value.find((song) => {
|
||||
const songTitle = normalizeString(song.title)
|
||||
const songArtist = normalizeString(song.artist)
|
||||
const titleMatch = songTitle === normalizedTitle && songArtist === normalizedArtist
|
||||
|
||||
// 必须匹配当前学期
|
||||
return titleMatch && song.semester === currentSemesterName
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
|
||||
const fetchRecentSongs = async () => {
|
||||
if (!props.cookie) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const { code, body, message } = await getRecentSongs(100, props.cookie)
|
||||
if (code === 200 && body && body.list) {
|
||||
songs.value = body.list.filter((item) => item.resourceType === 'SONG' && item.data)
|
||||
} else {
|
||||
error.value = message || '获取最近播放失败'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = '网络请求失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (newVal) => {
|
||||
if (newVal) {
|
||||
// 确保当前学期已加载,用于正确检查歌曲状态
|
||||
if (!currentSemester.value) {
|
||||
await fetchCurrentSemester()
|
||||
}
|
||||
|
||||
fetchRecentSongs()
|
||||
// 加载歌曲列表以便检查相似歌曲
|
||||
if (
|
||||
auth.isAuthenticated.value &&
|
||||
(!songService.songs.value || songService.songs.value.length === 0)
|
||||
) {
|
||||
songsLoadingForSimilar.value = true
|
||||
const currentSemesterName = currentSemester.value?.name
|
||||
songService
|
||||
.fetchSongs(true, currentSemesterName)
|
||||
.catch((err) => {
|
||||
console.error('加载歌曲列表失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
songsLoadingForSimilar.value = false
|
||||
})
|
||||
}
|
||||
} else {
|
||||
songs.value = []
|
||||
submitting.value = false
|
||||
selectedSongId.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleLike = async (song) => {
|
||||
if (!song || song.voted) {
|
||||
return
|
||||
}
|
||||
|
||||
if (song.played || song.scheduled) {
|
||||
if (window.$showNotification) {
|
||||
const message = song.played ? '已播放的歌曲不能点赞' : '已排期的歌曲不能点赞'
|
||||
window.$showNotification(message, 'warning')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await songService.voteSong(song.id)
|
||||
} catch (error) {
|
||||
console.error('点赞失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const playSong = (songData) => {
|
||||
// 简单的播放预览,如果需要完整播放器支持可能需要更多字段
|
||||
// 这里暂时只发射事件,由父组件决定如何播放
|
||||
// 实际上 RequestForm 可能会调用全局播放器
|
||||
// 但这里为了简化,我们暂时构造一个简单的对象
|
||||
emit('play', {
|
||||
id: songData.id,
|
||||
title: songData.name,
|
||||
artist: songData.ar?.map((a) => a.name).join('/'),
|
||||
cover: songData.al?.picUrl,
|
||||
musicPlatform: 'netease',
|
||||
musicId: songData.id.toString(),
|
||||
sourceInfo: {
|
||||
source: 'netease-backup',
|
||||
type: 'song'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectSong = (songData) => {
|
||||
selectedSongId.value = songData.id
|
||||
submitting.value = true
|
||||
|
||||
const song = {
|
||||
id: songData.id,
|
||||
title: songData.name,
|
||||
artist: songData.ar?.map((a) => a.name).join('/'),
|
||||
cover: songData.al?.picUrl,
|
||||
album: songData.al?.name,
|
||||
duration: songData.dt,
|
||||
musicPlatform: 'netease',
|
||||
musicId: songData.id.toString(),
|
||||
sourceInfo: {
|
||||
source: 'netease-backup',
|
||||
type: 'song'
|
||||
}
|
||||
}
|
||||
|
||||
emit('submit', song)
|
||||
// 不立即关闭,由父组件控制
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetSubmissionState: () => {
|
||||
submitting.value = false
|
||||
selectedSongId.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #4a5568;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #718096;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user