629 lines
18 KiB
Vue
629 lines
18 KiB
Vue
<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>
|