初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
@@ -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&timestamp=${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}&timestamp=${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