634 lines
19 KiB
Vue
634 lines
19 KiB
Vue
<template>
|
|
<Transition name="fade">
|
|
<div
|
|
v-if="show"
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-bg-primary-60 backdrop-blur-sm"
|
|
@click="closeDialog"
|
|
>
|
|
<Transition name="scale">
|
|
<div
|
|
v-if="show"
|
|
class="bg-bg-secondary border border-border-secondary 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-border-secondary shrink-0">
|
|
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest">
|
|
{{ locale.title }}
|
|
</h3>
|
|
<button
|
|
class="text-text-tertiary hover:text-text-secondary 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-warning-10 border border-warning-20 rounded-2xl p-4">
|
|
<p class="text-xs text-warning mb-3">{{ locale.loginRequired }}</p>
|
|
<button
|
|
class="w-full py-2 px-4 bg-primary-hover hover:bg-primary-hover text-text-primary text-xs font-bold rounded-xl transition-colors"
|
|
@click="showLoginModal"
|
|
>
|
|
{{ locale.loginNow }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<template v-else>
|
|
<!-- 音质选择 -->
|
|
<section class="space-y-3">
|
|
<label class="text-[10px] font-black uppercase text-text-disabled tracking-[0.2em] px-1"
|
|
>{{ locale.quality }}</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-primary-hover-10 border-primary shadow-sm'
|
|
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
|
|
]"
|
|
@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-primary' : 'text-text-primary'
|
|
"
|
|
>{{ option.label }}</span
|
|
>
|
|
<div
|
|
v-if="selectedQuality === option.value"
|
|
class="w-1.5 h-1.5 bg-primary rounded-full shadow-[0_0_8px_var(--primary-50)]"
|
|
/>
|
|
</div>
|
|
<span class="text-[10px] text-text-tertiary relative z-10">{{
|
|
option.description
|
|
}}</span>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 歌曲信息 -->
|
|
<section class="space-y-3">
|
|
<label class="text-[10px] font-black uppercase text-text-disabled tracking-[0.2em] px-1"
|
|
>{{ locale.songInfo }}</label
|
|
>
|
|
<div class="bg-bg-primary border border-border-secondary 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="locale.coverAlt"
|
|
class="w-12 h-12 rounded-lg object-cover"
|
|
>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-bold text-text-primary truncate">{{ songName }}</p>
|
|
<p class="text-xs text-text-tertiary truncate">{{ artistName }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 上传进度 -->
|
|
<section
|
|
v-if="uploading || uploadProgress > 0"
|
|
class="space-y-3 pt-4 border-t border-border-secondary-50"
|
|
>
|
|
<div
|
|
class="flex items-center justify-between text-[10px] font-bold uppercase tracking-wider"
|
|
>
|
|
<span class="text-text-tertiary">{{ uploadStatus }}</span>
|
|
<span class="text-primary">{{ uploadProgress }}%</span>
|
|
</div>
|
|
<div
|
|
class="h-2 bg-bg-primary rounded-full overflow-hidden border border-border-secondary-50 relative"
|
|
>
|
|
<div
|
|
class="h-full bg-gradient-to-r from-primary to-primary-hover transition-all duration-300 ease-out relative overflow-hidden"
|
|
:style="{ width: `${uploadProgress}%` }"
|
|
>
|
|
<div
|
|
class="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--shimmer-overlay)] to-transparent animate-shimmer"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p v-if="uploadMessage" class="text-[10px] text-text-tertiary text-center">
|
|
{{ uploadMessage }}
|
|
</p>
|
|
</section>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div v-if="isLoggedIn" class="p-4 border-t border-border-secondary shrink-0 flex gap-3">
|
|
<button
|
|
class="flex-1 py-3 px-4 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
|
|
:disabled="uploading"
|
|
@click="closeDialog"
|
|
>
|
|
{{ locale.cancel }}
|
|
</button>
|
|
<button
|
|
class="flex-1 py-3 px-4 bg-primary-hover hover:bg-primary-hover text-text-primary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed"
|
|
:disabled="uploading"
|
|
@click="startUpload"
|
|
>
|
|
{{ uploading ? locale.uploading : locale.startUpload }}
|
|
</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'
|
|
import { useLocale } from '~/utils/locale'
|
|
|
|
interface Props {
|
|
show: boolean
|
|
song: any // 来源歌曲对象
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
const { songs: songsLocale } = useLocale()
|
|
const requestLocale = computed(() => songsLocale.value?.requestForm || {})
|
|
const locale = computed(() => useSafeLocale(requestLocale.value?.neteaseUpload || {}))
|
|
|
|
const songName = computed(
|
|
() => props.song?.name || props.song?.song || props.song?.title || locale.value.unknownSong
|
|
)
|
|
const artistName = computed(() => props.song?.singer || props.song?.artist || locale.value.unknownArtist)
|
|
const albumName = computed(() => props.song?.album || locale.value.unknownAlbum)
|
|
|
|
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 getLocaleText = (key, ...args) => formatLocaleValue(locale.value?.[key], ...args)
|
|
|
|
// 检查登录状态
|
|
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 = locale.value.statusGetDownloadUrl
|
|
|
|
if (!strMediaMid) {
|
|
throw new Error(locale.value.missingSongId)
|
|
}
|
|
|
|
// 使用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(getLocaleText('requestFailed', 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(locale.value.invalidPlayUrl)
|
|
}
|
|
|
|
// 通过文件头识别音频格式
|
|
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 = locale.value.statusDownloading
|
|
|
|
const response = await fetch(url)
|
|
if (!response.ok) {
|
|
throw new Error(getLocaleText('downloadFailed', 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(locale.value.emptyResponse)
|
|
}
|
|
|
|
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 = locale.value.statusHashing
|
|
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 = locale.value.statusToken
|
|
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(getLocaleText('tokenFailed', tokenData.msg || tokenData.code))
|
|
}
|
|
|
|
const { needUpload, uploadUrl, uploadToken, objectKey, resourceId, songId } = tokenData.data
|
|
|
|
if (needUpload) {
|
|
// 上传到对象存储
|
|
uploadStatus.value = locale.value.statusUploading
|
|
|
|
// 使用上传事件获取进度
|
|
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(getLocaleText('nosFailed', xhr.status)))
|
|
}
|
|
})
|
|
|
|
xhr.addEventListener('error', () => {
|
|
reject(new Error(locale.value.nosNetworkError))
|
|
})
|
|
|
|
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 = locale.value.statusExists
|
|
}
|
|
|
|
// 完成上传并写入云盘信息
|
|
uploadStatus.value = locale.value.statusSaving
|
|
|
|
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(
|
|
getLocaleText('saveMaybeFailed', completeData.code)
|
|
)
|
|
} else {
|
|
throw new Error(getLocaleText('publishFailed', completeData.msg || completeData.code))
|
|
}
|
|
}
|
|
|
|
uploadProgress.value = 100
|
|
return completeData
|
|
}
|
|
|
|
// 开始上传流程
|
|
const startUpload = async () => {
|
|
if (uploading.value) return
|
|
|
|
uploading.value = true
|
|
uploadProgress.value = 0
|
|
uploadStatus.value = locale.value.statusPreparing
|
|
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(locale.value.missingResolvedId)
|
|
}
|
|
|
|
// 获取来源歌曲下载链接
|
|
uploadMessage.value = locale.value.fetchingQQUrl
|
|
|
|
const musicUrl = await getQQMusicUrl(musicId, selectedQuality.value)
|
|
|
|
if (!musicUrl) {
|
|
throw new Error(locale.value.missingMusicUrl)
|
|
}
|
|
|
|
// 下载音频文件
|
|
uploadMessage.value = locale.value.downloadingAudio
|
|
const { blob: audioBlob, ext } = await downloadAudio(musicUrl)
|
|
|
|
// 上传到网易云音乐云盘
|
|
uploadMessage.value = locale.value.uploadingCloud
|
|
const filename = `${artistName.value} - ${songName.value}.${ext}`
|
|
await uploadToNetease(audioBlob, filename)
|
|
|
|
uploadProgress.value = 100
|
|
uploadStatus.value = locale.value.statusDone
|
|
uploadMessage.value = locale.value.uploadSuccess
|
|
|
|
showSuccess(locale.value.uploadSuccess)
|
|
|
|
emit('upload-success')
|
|
|
|
// 成功后延迟关闭对话框
|
|
setTimeout(() => {
|
|
closeDialog()
|
|
}, 1500)
|
|
} catch (error: any) {
|
|
console.error('上传失败:', error)
|
|
uploadStatus.value = locale.value.statusFailed
|
|
uploadMessage.value = getErrorMessage(error) || locale.value.unknownError
|
|
|
|
showError(getLocaleText('uploadFailed', getErrorMessage(error)))
|
|
} 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: var(--overlay-10);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
background: var(--overlay-20);
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
0% {
|
|
transform: translateX(-100%);
|
|
}
|
|
100% {
|
|
transform: translateX(100%);
|
|
}
|
|
}
|
|
|
|
.animate-shimmer {
|
|
animation: shimmer 2s infinite;
|
|
}
|
|
</style>
|