feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,666 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-3xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-start justify-between p-8 pb-4 gap-4">
|
||||
<div class="flex items-start gap-4 min-w-0 flex-1">
|
||||
<div class="w-20 h-20 rounded-2xl overflow-hidden flex-shrink-0 bg-bg-tertiary shadow-lg">
|
||||
<img
|
||||
v-if="albumInfo && albumInfo.cover"
|
||||
:src="convertToHttps(albumInfo.cover)"
|
||||
:alt="albumInfo && albumInfo.name ? albumInfo.name : locale.albumFallback"
|
||||
class="w-full h-full object-cover"
|
||||
referrerpolicy="no-referrer"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full bg-primary-hover-10 flex items-center justify-center text-primary"
|
||||
>
|
||||
<Icon name="disc" :size="32" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight truncate">
|
||||
{{ albumInfo && albumInfo.name ? albumInfo.name : locale.albumFallback }}
|
||||
</h3>
|
||||
<p class="text-xs font-bold text-text-tertiary mt-1 truncate">
|
||||
{{ albumInfo && albumInfo.artist ? albumInfo.artist : locale.unknownArtist }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-1.5 text-[11px] text-text-disabled">
|
||||
<span v-if="albumInfo && albumInfo.size">
|
||||
{{ callLocale('songCount', `${albumInfo.size} 首`, albumInfo.size) }}
|
||||
</span>
|
||||
<span v-if="albumInfo && albumInfo.publishTime">
|
||||
{{ callLocale('publishTime', `发行时间: ${formatDate(albumInfo.publishTime)}`, formatDate(albumInfo.publishTime)) }}
|
||||
</span>
|
||||
<span v-if="albumInfo && albumInfo.company">
|
||||
{{ callLocale('company', `唱片公司: ${albumInfo.company}`, albumInfo.company) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 专辑描述 -->
|
||||
<p
|
||||
v-if="albumInfo && albumInfo.description"
|
||||
class="mt-2 text-[11px] text-text-tertiary leading-relaxed line-clamp-2 cursor-pointer hover:text-text-tertiary transition-colors"
|
||||
:title="locale.viewFullIntro"
|
||||
@click="showDescModal = true"
|
||||
>
|
||||
{{ albumInfo.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all flex-shrink-0"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="px-8 pb-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="locale.searchPlaceholder"
|
||||
class="w-full h-9 pl-9 pr-3 rounded-xl bg-bg-tertiary-60 border border-border-tertiary-50 text-text-primary text-sm placeholder-text-disabled focus:outline-none focus:border-primary-50 focus:bg-bg-tertiary transition-all"
|
||||
>
|
||||
<Icon name="search" :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-text-disabled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center py-12">
|
||||
<div class="flex flex-col items-center">
|
||||
<AppSpinner :size="48" class="mb-4" />
|
||||
<p class="text-sm font-bold text-text-tertiary uppercase tracking-widest">{{ locale.loading }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="flex-1 flex items-center justify-center py-12">
|
||||
<div class="flex flex-col items-center text-error">
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-error-10 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="alert-circle" :size="32" />
|
||||
</div>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div v-else class="flex-1 overflow-y-auto px-8 pt-2 pb-4 custom-scrollbar">
|
||||
<div
|
||||
v-if="filteredSongs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-text-tertiary"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-bg-tertiary-50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="music" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">
|
||||
{{ searchQuery ? locale.noMatches : locale.noSongs }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="song-list space-y-1.5">
|
||||
<div
|
||||
v-for="(song, index) in filteredSongs"
|
||||
:key="song.songmid"
|
||||
class="group flex items-center p-2 sm:p-3 rounded-2xl transition-all"
|
||||
:class="[
|
||||
isCurrentSong(song)
|
||||
? 'bg-primary-hover-10 border border-primary-50'
|
||||
: 'bg-bg-tertiary-30 border border-border-secondary-50 hover:bg-bg-tertiary-50 hover:border-border-tertiary'
|
||||
]"
|
||||
>
|
||||
<!-- 歌曲封面 -->
|
||||
<div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl overflow-hidden flex-shrink-0 mr-3 bg-bg-tertiary">
|
||||
<img
|
||||
v-if="song.img"
|
||||
:src="convertToHttps(song.img)"
|
||||
:alt="song.name"
|
||||
class="w-full h-full object-cover"
|
||||
referrerpolicy="no-referrer"
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center text-text-disabled"
|
||||
>
|
||||
<Icon name="music" :size="16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-text-primary text-sm sm:text-base whitespace-normal sm:whitespace-nowrap sm:truncate line-clamp-2 sm:line-clamp-none group-hover:text-text-primary transition-colors"
|
||||
>
|
||||
{{ song.name }}
|
||||
</h4>
|
||||
<div
|
||||
class="flex items-center gap-3 mt-0.5 text-[10px] text-text-tertiary font-bold uppercase tracking-wider"
|
||||
>
|
||||
<span class="truncate">{{ song.singer }}</span>
|
||||
<span class="flex items-center shrink-0">
|
||||
<Icon name="clock" :size="10" class="mr-1" />
|
||||
{{ song.interval }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="ml-2 sm:ml-4 shrink-0 flex items-center gap-2">
|
||||
<!-- 预听/暂停按钮 -->
|
||||
<button
|
||||
class="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl bg-bg-tertiary text-text-tertiary hover:bg-bg-quaternary hover:text-text-primary transition-all active:scale-95"
|
||||
:title="isCurrentSong(song) && isPlaying ? locale.pause : locale.preview"
|
||||
@click.stop="togglePlay(song)"
|
||||
>
|
||||
<Icon
|
||||
:name="isCurrentSong(song) && isPlaying ? 'pause' : 'play'"
|
||||
:size="16"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<template v-if="song.status.played">
|
||||
<!-- 已播放标签 -->
|
||||
<div
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-success-10 text-success border border-success-20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
>
|
||||
{{ requestLocale.played }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="song.status.scheduled">
|
||||
<!-- 已排期标签 -->
|
||||
<div
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-warning-10 text-warning border border-warning-20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
>
|
||||
{{ requestLocale.scheduled }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="song.status.submitted">
|
||||
<div class="flex flex-col sm:flex-row gap-2 items-center">
|
||||
<!-- 已投稿标签 -->
|
||||
<div
|
||||
class="w-full text-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-bg-tertiary text-text-tertiary text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.submitted }}
|
||||
</div>
|
||||
|
||||
<!-- 点赞按钮(非自己投稿) -->
|
||||
<button
|
||||
v-if="!isMySong(song)"
|
||||
:disabled="song.status.voted || submitting"
|
||||
:class="[
|
||||
'w-full sm:w-auto 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',
|
||||
song.status.voted
|
||||
? 'bg-error-10 text-error border border-error-20 cursor-not-allowed'
|
||||
: 'bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary hover:text-error'
|
||||
]"
|
||||
@click.stop="voteSong(song)"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-1">
|
||||
<Icon
|
||||
name="heart"
|
||||
:size="12"
|
||||
:class="song.status.voted ? 'fill-current' : ''"
|
||||
/>
|
||||
<span>{{ song.status.voted ? requestLocale.liked : requestLocale.like }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- 投稿按钮(未投稿) -->
|
||||
<button
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-[10px] sm:text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-[var(--primary-glow)]"
|
||||
@click.stop="selectSong(song)"
|
||||
>
|
||||
<span v-if="submitting && selectedSongId === song.songmid">{{ requestLocale.submitting }}</span>
|
||||
<span v-else>{{ locale.submit }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- 专辑简介弹窗 -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="showDescModal"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center p-6"
|
||||
@click.self="showDescModal = false"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
|
||||
<div
|
||||
class="relative w-full max-w-xl max-h-[70vh] bg-bg-secondary border border-border-secondary rounded-2xl p-6 overflow-y-auto custom-scrollbar"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-sm font-black text-text-primary uppercase tracking-widest">{{ locale.albumIntro }}</h4>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all"
|
||||
@click="showDescModal = false"
|
||||
>
|
||||
<Icon name="x" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-text-tertiary leading-relaxed whitespace-pre-wrap">
|
||||
{{ albumInfo?.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
|
||||
import { useAudioPlayer } from '~/composables/useAudioPlayer'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { normalizeForMatch as normalizeStr } from '~/utils/song-name-normalize'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
albumId: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
albumName: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
platform: {
|
||||
type: String,
|
||||
default: 'netease' // netease 或 tencent
|
||||
},
|
||||
submittedSongs: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'submit', 'play', 'vote'])
|
||||
|
||||
const { getCurrentSong, getPlayingStatus, pauseSong } = useAudioPlayer()
|
||||
const { songs: songsLocale } = useLocale()
|
||||
const fallbackRequestLocale = {
|
||||
played: 'Played',
|
||||
scheduled: 'Scheduled',
|
||||
liked: 'Liked',
|
||||
like: 'Like',
|
||||
submitting: 'Submitting...'
|
||||
}
|
||||
const fallbackAlbumModalLocale = {
|
||||
albumFallback: 'Album',
|
||||
unknownArtist: 'Unknown artist',
|
||||
songCount: (count) => `${count} songs`,
|
||||
publishTime: (date) => `Published: ${date}`,
|
||||
company: (company) => `Label: ${company}`,
|
||||
viewFullIntro: 'View full intro',
|
||||
searchPlaceholder: 'Search songs in this album...',
|
||||
loading: 'Loading...',
|
||||
noMatches: 'No matching songs found',
|
||||
noSongs: 'No songs',
|
||||
pause: 'Pause',
|
||||
preview: 'Preview',
|
||||
submitted: 'Submitted',
|
||||
submit: 'Submit',
|
||||
albumIntro: 'Album Intro',
|
||||
loadFailed: 'Load failed. Please try again later',
|
||||
fetchFailed: 'Failed to fetch album details',
|
||||
unsupportedPlatform: 'Unsupported platform',
|
||||
tencentUnsupported: 'QQ Music album details are not supported yet'
|
||||
}
|
||||
const requestLocale = computed(() => ({
|
||||
...fallbackRequestLocale,
|
||||
...(songsLocale.value?.requestForm || {})
|
||||
}))
|
||||
const locale = computed(() => ({
|
||||
...fallbackAlbumModalLocale,
|
||||
...(requestLocale.value?.albumModal || {})
|
||||
}))
|
||||
const { t: callLocale } = useLocaleText(locale)
|
||||
const currentSong = getCurrentSong()
|
||||
const isPlaying = getPlayingStatus()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const albumInfo = ref(null)
|
||||
const songs = ref([])
|
||||
const submitting = ref(false)
|
||||
const selectedSongId = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const showDescModal = ref(false)
|
||||
let albumAbortController = null
|
||||
|
||||
const songsWithStatus = computed(() => songs.value.map(s => ({ ...s, status: getSongStatus(s) })))
|
||||
|
||||
const filteredSongs = computed(() => {
|
||||
if (!searchQuery.value.trim()) return songsWithStatus.value
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
return songsWithStatus.value.filter(s =>
|
||||
(s.name?.toLowerCase() || '').includes(q) ||
|
||||
(s.singer?.toLowerCase() || '').includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
// 监听显示状态和专辑ID变化,加载专辑详情
|
||||
watch(
|
||||
[() => props.show, () => props.albumId],
|
||||
async ([show, albumId]) => {
|
||||
if (show && albumId) {
|
||||
searchQuery.value = ''
|
||||
await loadAlbumDetails()
|
||||
} else {
|
||||
if (albumAbortController) {
|
||||
albumAbortController.abort()
|
||||
albumAbortController = null
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const loadAlbumDetails = async () => {
|
||||
if (!props.albumId) return
|
||||
|
||||
if (albumAbortController) {
|
||||
albumAbortController.abort()
|
||||
}
|
||||
albumAbortController = new AbortController()
|
||||
const signal = albumAbortController.signal
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
albumInfo.value = null
|
||||
songs.value = []
|
||||
|
||||
try {
|
||||
if (props.platform === 'netease') {
|
||||
// 使用网易云音乐 API 获取专辑详情
|
||||
const response = await $fetch('/api/api-enhanced/netease/album', {
|
||||
params: {
|
||||
id: props.albumId
|
||||
},
|
||||
signal
|
||||
})
|
||||
|
||||
if (response && response.album && Array.isArray(response.songs)) {
|
||||
// 转换为统一格式
|
||||
const album = response.album
|
||||
const artistName = album.artist?.name || album.artists?.map(a => a?.name).filter(Boolean).join('/') || locale.value.unknownArtist
|
||||
|
||||
albumInfo.value = {
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
cover: album.picUrl,
|
||||
artist: artistName,
|
||||
publishTime: album.publishTime,
|
||||
description: album.description,
|
||||
company: album.company,
|
||||
size: album.size
|
||||
}
|
||||
|
||||
songs.value = response.songs.map((item, i) => {
|
||||
const singerName = item.ar?.map(a => a?.name).filter(Boolean).join('/') || locale.value.unknownArtist
|
||||
const durationMs = item.dt || 0
|
||||
return {
|
||||
songmid: item.id,
|
||||
name: item.name,
|
||||
singer: singerName,
|
||||
albumName: album.name,
|
||||
albumId: album.id,
|
||||
img: item.al?.picUrl || album.picUrl,
|
||||
interval: formatTime(Math.floor(durationMs / 1000)),
|
||||
duration: durationMs / 1000,
|
||||
trackNumber: item.no || i + 1,
|
||||
source: 'netease'
|
||||
}
|
||||
})
|
||||
} else {
|
||||
throw new Error(locale.value.fetchFailed)
|
||||
}
|
||||
} else if (props.platform === 'tencent') {
|
||||
// QQ音乐暂不支持,提示用户
|
||||
error.value = locale.value.tencentUnsupported
|
||||
} else {
|
||||
error.value = locale.value.unsupportedPlatform
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') return
|
||||
console.error('加载专辑详情失败:', err)
|
||||
error.value = locale.value.loadFailed
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
if (isNaN(seconds) || seconds < 0) return '0:00'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return minutes + ':' + secs.toString().padStart(2, '0')
|
||||
}
|
||||
|
||||
const formatDate = (timestamp) => {
|
||||
if (!timestamp) return null
|
||||
const parsedTimestamp = typeof timestamp === 'string' && /^\d+$/.test(timestamp) ? parseInt(timestamp, 10) : timestamp
|
||||
const date = new Date(parsedTimestamp)
|
||||
if (isNaN(date.getTime())) return null
|
||||
return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0')
|
||||
}
|
||||
|
||||
const isCurrentSong = (song) => {
|
||||
if (!currentSong.value) return false
|
||||
|
||||
// 使用 songmid 匹配
|
||||
return String(currentSong.value.musicId) === String(song.songmid) ||
|
||||
String(currentSong.value.id) === String(song.songmid)
|
||||
}
|
||||
|
||||
const submittedSongsMap = computed(() => {
|
||||
const map = new Map()
|
||||
props.submittedSongs.forEach((s) => {
|
||||
if (s.musicId) {
|
||||
map.set(String(s.musicId), s)
|
||||
}
|
||||
const key = normalizeStr(s.title) + '|' + normalizeStr(s.artist)
|
||||
map.set(key, s)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const getSongStatus = (song) => {
|
||||
const songmidStr = String(song.songmid)
|
||||
const key = normalizeStr(song.name) + '|' + normalizeStr(song.singer)
|
||||
const submittedSong = submittedSongsMap.value.get(songmidStr) || submittedSongsMap.value.get(key)
|
||||
|
||||
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: false,
|
||||
scheduled: false
|
||||
}
|
||||
}
|
||||
|
||||
const isMySong = (song) => {
|
||||
if (!props.currentUserId) return false
|
||||
return (song.status || getSongStatus(song)).requesterId === props.currentUserId
|
||||
}
|
||||
|
||||
const togglePlay = (song) => {
|
||||
if (isCurrentSong(song) && isPlaying.value) {
|
||||
pauseSong()
|
||||
} else {
|
||||
playSong(song)
|
||||
}
|
||||
}
|
||||
|
||||
const playSong = (song) => {
|
||||
// 构建所有歌曲的播放列表
|
||||
const playlist = songs.value.map(s => ({
|
||||
id: s.songmid,
|
||||
title: s.name,
|
||||
song: s.name,
|
||||
artist: s.singer,
|
||||
singer: s.singer,
|
||||
cover: s.img,
|
||||
album: s.albumName,
|
||||
albumId: s.albumId,
|
||||
duration: s.duration * 1000,
|
||||
musicPlatform: props.platform,
|
||||
musicId: s.songmid,
|
||||
sourceInfo: {
|
||||
source: props.platform === 'tencent' ? 'vkeys' : 'netease-backup',
|
||||
originalId: s.songmid,
|
||||
fetchedAt: new Date(),
|
||||
mid: s.songmid
|
||||
}
|
||||
}))
|
||||
const currentIndex = playlist.findIndex(p => p.id === song.songmid)
|
||||
|
||||
emit('play', { song: playlist[currentIndex], playlist, playlistIndex: currentIndex })
|
||||
}
|
||||
|
||||
const selectSong = (song) => {
|
||||
if (submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
selectedSongId.value = song.songmid
|
||||
|
||||
// 转换为投稿格式
|
||||
const songData = {
|
||||
id: song.songmid,
|
||||
title: song.name,
|
||||
song: song.name,
|
||||
artist: song.singer,
|
||||
singer: song.singer,
|
||||
cover: song.img,
|
||||
album: song.albumName,
|
||||
albumId: song.albumId,
|
||||
duration: song.duration * 1000,
|
||||
musicPlatform: props.platform,
|
||||
musicId: song.songmid,
|
||||
sourceInfo: {
|
||||
source: props.platform === 'tencent' ? 'vkeys' : 'netease-backup',
|
||||
originalId: song.songmid,
|
||||
fetchedAt: new Date(),
|
||||
mid: song.songmid
|
||||
}
|
||||
}
|
||||
|
||||
emit('submit', songData)
|
||||
}
|
||||
|
||||
const voteSong = (song) => {
|
||||
if (submitting.value) return
|
||||
|
||||
const { songId, voted } = getSongStatus(song)
|
||||
|
||||
if (!songId || voted) return
|
||||
|
||||
emit('vote', { ...song, songId, voted })
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
// 重置状态
|
||||
submitting.value = false
|
||||
selectedSongId.value = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (albumAbortController) {
|
||||
albumAbortController.abort()
|
||||
albumAbortController = null
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露方法给父组件,用于在提交失败时重置状态
|
||||
const resetSubmissionState = () => {
|
||||
submitting.value = false
|
||||
selectedSongId.value = null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetSubmissionState
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--overlay-10);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--overlay-20);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,402 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-2xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-8 pb-4">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<div class="w-12 h-12 rounded-2xl overflow-hidden flex-shrink-0 bg-bg-tertiary">
|
||||
<img
|
||||
v-if="video?.cover"
|
||||
:src="video.cover"
|
||||
:alt="video.title"
|
||||
class="w-full h-full object-cover"
|
||||
referrerpolicy="no-referrer"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full bg-primary-hover-10 flex items-center justify-center text-primary"
|
||||
>
|
||||
<Icon name="play" :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight truncate">
|
||||
{{ video?.title }}
|
||||
</h3>
|
||||
<p class="text-xs font-bold text-text-tertiary uppercase tracking-widest mt-1">
|
||||
{{ locale.uploader }}{{ video?.artist }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all flex-shrink-0"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主体 -->
|
||||
<div class="flex-1 overflow-y-auto p-8 pt-4 custom-scrollbar">
|
||||
<div
|
||||
v-if="episodes.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-text-tertiary"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-bg-tertiary-50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="play" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">{{ locale.empty }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="program-list space-y-3">
|
||||
<div
|
||||
v-for="episode in episodes"
|
||||
:key="episode.cid"
|
||||
class="group flex items-center p-3 sm:p-4 rounded-3xl transition-all"
|
||||
:class="[
|
||||
isCurrentEpisode(episode)
|
||||
? 'bg-primary-hover-10 border border-primary-50'
|
||||
: 'bg-bg-tertiary-30 border border-border-secondary-50 hover:bg-bg-tertiary-50 hover:border-border-tertiary'
|
||||
]"
|
||||
>
|
||||
<!-- 剧集编号 -->
|
||||
<div
|
||||
class="w-8 h-8 sm:w-12 sm:h-12 rounded-2xl bg-bg-tertiary flex items-center justify-center text-text-tertiary font-black text-xs sm:text-sm flex-shrink-0 mr-3 sm:mr-4 group-hover:text-text-secondary transition-colors"
|
||||
>
|
||||
P{{ episode.page }}
|
||||
</div>
|
||||
|
||||
<!-- 剧集信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-text-primary text-sm sm:text-base whitespace-normal sm:whitespace-nowrap sm:truncate line-clamp-2 sm:line-clamp-none group-hover:text-text-primary transition-colors"
|
||||
>
|
||||
{{ episode.part }}
|
||||
</h4>
|
||||
<div
|
||||
class="flex items-center gap-3 mt-1 text-[10px] text-text-tertiary font-bold uppercase tracking-wider"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<Icon name="clock" :size="10" class="mr-1" />
|
||||
{{ formatDuration(episode.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="ml-2 sm:ml-4 shrink-0 flex items-center gap-2 sm:gap-3">
|
||||
<!-- 预听/暂停按钮 -->
|
||||
<button
|
||||
class="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl bg-bg-tertiary text-text-tertiary hover:bg-bg-quaternary hover:text-text-primary transition-all active:scale-95"
|
||||
:title="isCurrentEpisode(episode) && isPlaying ? locale.pause : locale.preview"
|
||||
@click.stop="togglePlay(episode)"
|
||||
>
|
||||
<Icon
|
||||
:name="isCurrentEpisode(episode) && isPlaying ? 'pause' : 'play'"
|
||||
:size="16"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<template v-if="getEpisodeStatus(episode).played">
|
||||
<button
|
||||
v-if="allowPlayedResubmit"
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-[10px] sm:text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-[var(--primary-glow)]"
|
||||
@click.stop="selectEpisode(episode)"
|
||||
>
|
||||
<span v-if="submitting && selectedEpisodeCid === episode.cid">{{ locale.submitting }}</span>
|
||||
<span v-else><span class="hidden sm:inline">{{ locale.select }}</span>{{ locale.submit }}</span>
|
||||
</button>
|
||||
<!-- 已播放标签 -->
|
||||
<div
|
||||
v-else
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-success-10 text-success border border-success-20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.played }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="getEpisodeStatus(episode).scheduled">
|
||||
<!-- 已排期标签 -->
|
||||
<div
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-warning-10 text-warning border border-warning-20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.scheduled }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="getEpisodeStatus(episode).submitted">
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<!-- 已投稿标签 -->
|
||||
<div
|
||||
class="w-full text-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-bg-tertiary text-text-tertiary text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.submitted }}
|
||||
</div>
|
||||
|
||||
<!-- 点赞按钮(非自己投稿) -->
|
||||
<button
|
||||
v-if="!isMyEpisode(episode)"
|
||||
:disabled="getEpisodeStatus(episode).voted || submitting"
|
||||
:class="[
|
||||
'w-full px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest transition-all active:scale-95',
|
||||
getEpisodeStatus(episode).voted
|
||||
? 'bg-error-10 text-error border border-error-20 cursor-not-allowed'
|
||||
: 'bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary hover:text-error'
|
||||
]"
|
||||
@click.stop="voteEpisode(episode)"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-1">
|
||||
<Icon
|
||||
name="heart"
|
||||
:size="12"
|
||||
:class="getEpisodeStatus(episode).voted ? 'fill-current' : ''"
|
||||
/>
|
||||
<span>{{ getEpisodeStatus(episode).voted ? locale.liked : locale.like }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- 选择投稿按钮(未投稿) -->
|
||||
<button
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-[10px] sm:text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-[var(--primary-glow)]"
|
||||
@click.stop="selectEpisode(episode)"
|
||||
>
|
||||
<span v-if="submitting && selectedEpisodeCid === episode.cid">{{ locale.submitting }}</span>
|
||||
<span v-else><span class="hidden sm:inline">{{ locale.select }}</span>{{ locale.submit }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useAudioPlayer } from '~/composables/useAudioPlayer'
|
||||
import { isBilibiliSong } from '~/utils/bilibiliSource'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const { songs } = useLocale()
|
||||
const locale = computed(() => songs.value?.bilibiliEpisodesModal || {})
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
video: Object,
|
||||
episodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
submittedEpisodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
allowPlayedResubmit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'submit', 'play', 'vote'])
|
||||
|
||||
const { getCurrentSong, getPlayingStatus, pauseSong } = useAudioPlayer()
|
||||
const currentSong = getCurrentSong()
|
||||
const isPlaying = getPlayingStatus()
|
||||
|
||||
const submitting = ref(false)
|
||||
const selectedEpisodeCid = ref(null)
|
||||
|
||||
// 计算已投稿状态
|
||||
const submittedCount = computed(() => props.submittedEpisodes.length)
|
||||
const totalEpisodes = computed(() => props.episodes.length)
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const isCurrentEpisode = (episode) => {
|
||||
if (!currentSong.value) return false
|
||||
|
||||
if (!isBilibiliSong(currentSong.value)) return false
|
||||
|
||||
// 检查 CID
|
||||
if (
|
||||
currentSong.value.bilibiliCid &&
|
||||
String(currentSong.value.bilibiliCid) === String(episode.cid)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 兼容逻辑:检查 musicId 格式是否为 bvid:cid
|
||||
if (currentSong.value.musicId && currentSong.value.musicId.includes(':')) {
|
||||
const [, cid] = currentSong.value.musicId.split(':')
|
||||
return cid === String(episode.cid)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取 episode 的完整状态(包括 voted)
|
||||
const getEpisodeStatus = (episode) => {
|
||||
const submittedSong = props.submittedEpisodes.find((song) => {
|
||||
if (!song.musicId) return false
|
||||
const cid = song.musicId.includes(':') ? song.musicId.split(':')[1] : null
|
||||
return cid === String(episode.cid)
|
||||
})
|
||||
|
||||
if (submittedSong) {
|
||||
return {
|
||||
submitted: true,
|
||||
voted: submittedSong.voted || false,
|
||||
songId: submittedSong.id,
|
||||
requesterId: submittedSong.requesterId,
|
||||
played: submittedSong.played || false,
|
||||
scheduled: submittedSong.scheduled || false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
submitted: false,
|
||||
voted: false,
|
||||
songId: null,
|
||||
requesterId: null,
|
||||
played: episode.played || false,
|
||||
scheduled: episode.scheduled || false
|
||||
}
|
||||
}
|
||||
|
||||
const isMyEpisode = (episode) => {
|
||||
if (!props.currentUserId) return false
|
||||
|
||||
// 使用 getEpisodeStatus 获取统一的状态信息,避免直接修改 props
|
||||
const status = getEpisodeStatus(episode)
|
||||
return status.requesterId === props.currentUserId
|
||||
}
|
||||
|
||||
const togglePlay = (episode) => {
|
||||
if (isCurrentEpisode(episode) && isPlaying.value) {
|
||||
pauseSong()
|
||||
} else {
|
||||
playEpisode(episode)
|
||||
}
|
||||
}
|
||||
|
||||
const playEpisode = (episode) => {
|
||||
const episodeData = {
|
||||
...episode,
|
||||
cover: props.video?.cover,
|
||||
bvid: props.video?.id,
|
||||
title: props.video?.title,
|
||||
artist: props.video?.artist
|
||||
}
|
||||
|
||||
// 构建所有分P的播放列表
|
||||
const playlist = props.episodes.map(e => ({
|
||||
...e,
|
||||
cover: props.video?.cover,
|
||||
bvid: props.video?.id,
|
||||
title: props.video?.title,
|
||||
artist: props.video?.artist,
|
||||
musicPlatform: 'bilibili',
|
||||
musicId: props.video?.id,
|
||||
bilibiliCid: e.cid
|
||||
}))
|
||||
const currentIndex = playlist.findIndex(e => e.cid === episode.cid)
|
||||
|
||||
emit('play', { song: episodeData, playlist, playlistIndex: currentIndex })
|
||||
}
|
||||
|
||||
const selectEpisode = (episode) => {
|
||||
const status = getEpisodeStatus(episode)
|
||||
if (submitting.value || (status.submitted && !(props.allowPlayedResubmit && status.played))) return
|
||||
submitting.value = true
|
||||
selectedEpisodeCid.value = episode.cid
|
||||
emit('submit', { episode, status })
|
||||
}
|
||||
|
||||
const voteEpisode = (episode) => {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 getEpisodeStatus 获取统一的状态信息,避免重复查找
|
||||
const { songId, voted } = getEpisodeStatus(episode)
|
||||
|
||||
if (!songId || voted) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('vote', { ...episode, songId })
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
// 重置状态
|
||||
submitting.value = false
|
||||
selectedEpisodeCid.value = null
|
||||
}
|
||||
|
||||
// 暴露方法给父组件,用于在提交失败时重置状态
|
||||
const resetSubmissionState = () => {
|
||||
submitting.value = false
|
||||
selectedEpisodeCid.value = null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetSubmissionState
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--overlay-10);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--overlay-20);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<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-bg-primary-80 backdrop-blur-sm"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<div
|
||||
class="bg-bg-secondary border border-border-secondary 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-text-primary tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-warning-10 flex items-center justify-center text-warning"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="20" />
|
||||
</div>
|
||||
{{ locale.title }}
|
||||
</h3>
|
||||
<p class="text-xs text-text-tertiary mt-1 ml-13">{{ locale.desc }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary 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-text-tertiary leading-relaxed font-medium">
|
||||
{{ locale.tip }}
|
||||
</p>
|
||||
|
||||
<!-- 歌曲卡片 -->
|
||||
<div
|
||||
class="flex gap-4 p-5 bg-bg-primary border border-border-secondary rounded-3xl group shadow-xl"
|
||||
>
|
||||
<div
|
||||
class="w-20 h-20 rounded-2xl overflow-hidden bg-bg-secondary flex-shrink-0 border border-border-secondary"
|
||||
>
|
||||
<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-text-secondary">
|
||||
<Icon name="music" :size="32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 py-1">
|
||||
<h4 class="text-base font-black text-text-primary truncate mb-1">{{ song.title }}</h4>
|
||||
<p
|
||||
class="text-[10px] font-black uppercase tracking-widest text-text-tertiary 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-error uppercase tracking-widest"
|
||||
>
|
||||
<Icon name="heart" :size="12" class="fill-current" />
|
||||
{{ getLocaleMessage('votes', song.voteCount || 0) }}
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-widest border',
|
||||
song.played
|
||||
? 'bg-success-10 text-success border-success-20'
|
||||
: 'bg-warning-10 text-warning border-warning-20'
|
||||
]"
|
||||
>
|
||||
{{ song.played ? locale.played : locale.pending }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-text-disabled font-black uppercase tracking-widest mt-2">
|
||||
{{ locale.requester }}{{ song.requester }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="p-8 pt-0 flex gap-3">
|
||||
<button
|
||||
class="flex-1 px-6 py-4 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black rounded-2xl transition-all uppercase tracking-widest active:scale-95"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ locale.back }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="liking || song.voted"
|
||||
class="flex-[2] px-6 py-4 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-2xl shadow-lg shadow-[var(--primary-glow)] 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 ? locale.liked : locale.likeNow }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Song } from '~/types'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
song: Song
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { songs } = useLocale()
|
||||
const locale = computed(() => songs.value?.duplicateSongModal || {})
|
||||
const { msg: getLocaleMessage } = useLocaleText(locale)
|
||||
|
||||
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,487 @@
|
||||
<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-bg-primary-80 backdrop-blur-sm"
|
||||
@click.self="close"
|
||||
>
|
||||
<div
|
||||
class="bg-bg-secondary border border-border-secondary 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-border-secondary-50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-success-10 flex items-center justify-center text-success"
|
||||
>
|
||||
<Icon name="success" :size="20" />
|
||||
</div>
|
||||
{{ locale.resultTitle }}
|
||||
</h3>
|
||||
<p class="text-xs text-text-tertiary mt-1 ml-13">{{ locale.resultDesc }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary 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-success-5 border border-success-20 rounded-3xl flex flex-col items-center gap-2"
|
||||
>
|
||||
<span class="text-[10px] font-black text-success uppercase tracking-widest"
|
||||
>{{ locale.successCount }}</span
|
||||
>
|
||||
<span class="text-4xl font-black text-success">{{
|
||||
importResult.success
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
class="p-6 bg-error-5 border border-error-20 rounded-3xl flex flex-col items-center gap-2"
|
||||
>
|
||||
<span class="text-[10px] font-black text-error uppercase tracking-widest"
|
||||
>{{ locale.failedCount }}</span
|
||||
>
|
||||
<span class="text-4xl font-black text-error">{{ 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-text-tertiary uppercase tracking-widest ml-1"
|
||||
>
|
||||
<Icon name="info" :size="12" />
|
||||
{{ locale.details }}
|
||||
</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-bg-primary border border-border-secondary rounded-xl text-xs text-text-tertiary font-medium"
|
||||
>
|
||||
{{ detail }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center py-10 text-success-50"
|
||||
>
|
||||
<Icon name="success" :size="48" class="mb-4" />
|
||||
<p class="text-sm font-bold uppercase tracking-widest">{{ locale.allSuccess }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-8 pt-4 border-t border-border-secondary-50 bg-bg-secondary-50">
|
||||
<button
|
||||
class="w-full px-6 py-4 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black rounded-2xl transition-all uppercase tracking-widest shadow-lg active:scale-95"
|
||||
@click="close"
|
||||
>
|
||||
{{ locale.done }}
|
||||
</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-border-secondary-50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-primary-hover-10 flex items-center justify-center text-primary"
|
||||
>
|
||||
<Icon name="download" :size="20" />
|
||||
</div>
|
||||
{{ locale.title }}
|
||||
</h3>
|
||||
<p class="text-xs text-text-tertiary mt-1 ml-13">{{ locale.desc }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary 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-border-secondary-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-text-tertiary uppercase tracking-widest ml-1"
|
||||
>{{ locale.selectSemester }}</label
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="selectedSemester"
|
||||
:options="semesterList"
|
||||
label-key="name"
|
||||
value-key="name"
|
||||
:placeholder="locale.semesterPlaceholder"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex bg-bg-primary p-1 rounded-xl border border-border-secondary">
|
||||
<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-primary-hover text-text-primary shadow-lg'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
]"
|
||||
@click="filterType = type"
|
||||
>
|
||||
{{ getFilterLabel(type) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar bg-bg-primary-30">
|
||||
<div
|
||||
v-if="loadingSemesters || loadingSongs"
|
||||
class="flex flex-col items-center justify-center py-20 text-text-tertiary"
|
||||
>
|
||||
<Icon name="refresh" :size="32" class="animate-spin mb-4 text-primary" />
|
||||
<div class="text-[10px] font-black uppercase tracking-widest">{{ locale.loadingSongs }}</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-error-10 flex items-center justify-center text-error mb-4"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="32" />
|
||||
</div>
|
||||
<p class="text-sm text-text-tertiary mb-6">{{ error }}</p>
|
||||
<button
|
||||
class="px-6 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-primary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="loadSongs"
|
||||
>
|
||||
{{ locale.retry }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!selectedSemester"
|
||||
class="flex flex-col items-center justify-center py-20 text-text-disabled"
|
||||
>
|
||||
<Icon name="list" :size="48" class="mb-4 opacity-20" />
|
||||
<p class="text-sm font-bold uppercase tracking-widest">{{ locale.selectSemesterFirst }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredSongs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-20 text-text-disabled"
|
||||
>
|
||||
<Icon name="search" :size="48" class="mb-4 opacity-20" />
|
||||
<p class="text-sm font-bold uppercase tracking-widest">{{ locale.noSongs }}</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-primary-hover-10 border-primary-30 shadow-lg'
|
||||
: 'bg-bg-secondary border-transparent hover:border-border-secondary'
|
||||
]"
|
||||
@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-primary-hover border-primary text-text-primary'
|
||||
: 'border-border-secondary text-transparent'
|
||||
]"
|
||||
>
|
||||
<Icon name="check" :size="14" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl overflow-hidden bg-bg-tertiary 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-text-primary truncate mb-0.5">
|
||||
{{ song.title }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-text-tertiary truncate">{{ song.artist }}</span>
|
||||
<span class="text-[10px] text-text-secondary font-bold">•</span>
|
||||
<span class="text-[10px] text-text-disabled 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-success-10 text-success border-success-20'
|
||||
: 'bg-bg-tertiary text-text-tertiary border-border-tertiary'
|
||||
]"
|
||||
>
|
||||
{{ song.played ? locale.collected : locale.notCollected }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-8 border-t border-border-secondary-50 bg-bg-secondary-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-primary hover:text-primary uppercase tracking-widest transition-colors"
|
||||
@click="toggleSelectAll"
|
||||
>
|
||||
{{ isAllSelected ? locale.cancelSelectAll : locale.selectAll }}
|
||||
</button>
|
||||
<div class="h-4 w-px bg-bg-tertiary hidden md:block" />
|
||||
<span class="text-[10px] font-black text-text-tertiary uppercase tracking-widest">
|
||||
{{ formatLocale(locale.selectedSongs, selectedSongIds.size) }}
|
||||
</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-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
|
||||
@click="close"
|
||||
>
|
||||
{{ requestLocale.cancel || '取消' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-[2] md:flex-none px-8 py-4 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg shadow-[var(--primary-glow)] 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 ? (locale.importing || '正在导入...') : (locale.confirmImport || '确认导入') }}
|
||||
</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'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'import-success'])
|
||||
const { songs: songsLocale } = useLocale()
|
||||
const requestLocale = computed(() => songsLocale.value?.requestForm || {})
|
||||
const locale = computed(() => requestLocale.value?.importSongsModal || {})
|
||||
|
||||
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: locale.value.unplayed,
|
||||
played: locale.value.played,
|
||||
all: locale.value.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 = locale.value.loadSemestersFailed
|
||||
} 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'
|
||||
}
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
songs.value = res.data.songs
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取歌曲失败', e)
|
||||
error.value = locale.value.loadSongsFailed
|
||||
} 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 || locale.value.importFailed, '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,269 @@
|
||||
<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-bg-primary-60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-sm bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-border-secondary-50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-primary-hover-10 flex items-center justify-center text-primary"
|
||||
>
|
||||
<Icon name="music" :size="20" />
|
||||
</div>
|
||||
{{ locale.neteaseTitle }}
|
||||
</h3>
|
||||
<p class="text-xs text-text-tertiary mt-1 ml-13">{{ locale.neteaseDesc }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary 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-text-tertiary">
|
||||
<AppSpinner :size="48" class="mb-4" />
|
||||
<p class="font-bold uppercase tracking-widest text-[10px]">{{ locale.loadingQr }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="qrImg" class="relative group">
|
||||
<div
|
||||
class="p-4 bg-bg-secondary 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 rounded-2xl object-contain"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isExpired"
|
||||
class="absolute inset-0 bg-bg-secondary-90 backdrop-blur-sm rounded-3xl flex items-center justify-center cursor-pointer transition-all hover:bg-bg-secondary-80"
|
||||
@click="initLogin"
|
||||
>
|
||||
<div class="flex flex-col items-center text-text-primary">
|
||||
<Icon name="refresh" :size="40" class="mb-3 text-text-tertiary" />
|
||||
<span class="font-black uppercase tracking-widest text-xs">{{ locale.qrExpired }}</span>
|
||||
<span class="text-[10px] text-text-tertiary mt-1 font-bold">{{ locale.clickRefresh }}</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-text-tertiary text-xs font-black uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.expiredRefresh }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="status === 801"
|
||||
class="text-text-tertiary text-xs font-black uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.neteaseWaiting }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="status === 802"
|
||||
class="text-primary text-xs font-black uppercase tracking-widest flex items-center justify-center"
|
||||
>
|
||||
<Icon name="check" :size="16" class="mr-2" />
|
||||
{{ locale.scanSuccess }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="status === 803"
|
||||
class="text-success text-xs font-black uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.neteaseSuccess }}
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 说明提示 -->
|
||||
<div class="mt-8 p-4 bg-bg-tertiary-30 rounded-2xl border border-border-secondary-50 w-full">
|
||||
<p
|
||||
class="text-[10px] leading-relaxed text-text-tertiary text-center uppercase tracking-[0.15em] font-black"
|
||||
>
|
||||
{{ locale.neteaseTip }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { songs } = useLocale()
|
||||
const locale = computed(() => songs.value?.musicLoginModal || {})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'login-success', data: { cookie: string; user: any }): void
|
||||
}>()
|
||||
|
||||
const BASE_URL = '/api/api-enhanced/netease'
|
||||
|
||||
const qrImg = ref('')
|
||||
const loading = ref(false)
|
||||
const status = ref(0) // 800已过期、801待扫码、802已扫码、803登录成功
|
||||
const isExpired = ref(false)
|
||||
let timer: any = null
|
||||
let unikey = ''
|
||||
|
||||
const handleClose = () => {
|
||||
stopPolling()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const initLogin = async () => {
|
||||
stopPolling()
|
||||
loading.value = true
|
||||
isExpired.value = false
|
||||
status.value = 0
|
||||
|
||||
try {
|
||||
// 获取二维码登录密钥
|
||||
const keyRes = await fetch(`${BASE_URL}/login/qr/key?timestamp=${Date.now()}`)
|
||||
const keyData = await keyRes.json()
|
||||
unikey = keyData.data.unikey
|
||||
|
||||
// 生成二维码
|
||||
const qrRes = await fetch(
|
||||
`${BASE_URL}/login/qr/create?key=${unikey}&qrimg=true×tamp=${Date.now()}&ua=pc`
|
||||
)
|
||||
const qrData = await qrRes.json()
|
||||
qrImg.value = qrData.data.qrimg
|
||||
status.value = 801
|
||||
|
||||
// 启动轮询
|
||||
timer = setInterval(checkStatus, 3000)
|
||||
} catch (err) {
|
||||
console.error('初始化登录失败:', err)
|
||||
status.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const checkStatus = async () => {
|
||||
if (!unikey) return
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BASE_URL}/login/qr/check?key=${unikey}×tamp=${Date.now()}&ua=pc`
|
||||
)
|
||||
const data = await res.json()
|
||||
status.value = data.code
|
||||
|
||||
if (data.code === 800) {
|
||||
// 已过期
|
||||
isExpired.value = true
|
||||
stopPolling()
|
||||
} else if (data.code === 803) {
|
||||
// 登录成功
|
||||
stopPolling()
|
||||
const cookie = data.cookie
|
||||
await handleLoginSuccess(cookie)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('检查二维码状态失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoginSuccess = async (cookie: string) => {
|
||||
try {
|
||||
// 通过登录凭证获取用户信息
|
||||
const userRes = await fetch(`${BASE_URL}/login/status?timestamp=${Date.now()}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ cookie })
|
||||
})
|
||||
const res = await userRes.json()
|
||||
|
||||
const userInfo = {
|
||||
cookie,
|
||||
user: {
|
||||
userId: res.data?.profile?.userId,
|
||||
id: res.data?.profile?.userId,
|
||||
nickname: res.data?.profile?.nickname,
|
||||
avatarUrl: res.data?.profile?.avatarUrl,
|
||||
userName: res.data?.profile?.nickname
|
||||
}
|
||||
}
|
||||
|
||||
emit('login-success', userInfo)
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
console.error('获取用户信息失败:', err)
|
||||
emit('login-success', { cookie, user: null })
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听显示属性以初始化/停止
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
initLogin()
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,633 @@
|
||||
<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>
|
||||
@@ -0,0 +1,637 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-2xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-8 pb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
v-if="view === 'songs'"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all"
|
||||
@click="backToPlaylists"
|
||||
>
|
||||
<Icon name="arrow-left" :size="20" />
|
||||
</button>
|
||||
<h3
|
||||
class="text-xl font-black text-text-primary tracking-tight truncate max-w-[300px] sm:max-w-md"
|
||||
>
|
||||
{{ view === 'playlists' ? locale.selectPlaylist : selectedPlaylist?.name || locale.playlistDetails }}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary 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-text-tertiary"
|
||||
>
|
||||
<Icon name="loader" :size="48" class="mb-4 animate-spin text-text-tertiary" />
|
||||
<p class="font-medium">{{ locale.processing }}</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-error-10 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="alert-circle" :size="32" class="text-error" />
|
||||
</div>
|
||||
<p class="text-text-tertiary font-medium mb-6">{{ error }}</p>
|
||||
<button
|
||||
class="px-8 py-3 rounded-xl bg-bg-tertiary hover:bg-bg-quaternary text-text-primary text-xs font-black transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click="retry"
|
||||
>
|
||||
{{ locale.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-text-tertiary"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-bg-tertiary-50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="music" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="font-medium">{{ locale.emptyPlaylists }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="playlist in playlists"
|
||||
v-else
|
||||
:key="playlist.id"
|
||||
class="group flex items-center p-4 bg-bg-tertiary-30 border border-border-secondary-50 rounded-3xl hover:bg-bg-tertiary-50 hover:border-border-tertiary transition-all cursor-pointer"
|
||||
@click="selectPlaylist(playlist)"
|
||||
>
|
||||
<div class="w-16 h-16 rounded-2xl overflow-hidden bg-bg-tertiary mr-4 flex-shrink-0">
|
||||
<img
|
||||
v-if="playlist.coverImgUrl"
|
||||
:src="convertToHttps(playlist.coverImgUrl)"
|
||||
alt="cover"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center bg-bg-quaternary-50"
|
||||
>
|
||||
<Icon name="heart" :size="22" class="text-text-disabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-text-primary truncate group-hover:text-text-primary transition-colors"
|
||||
>
|
||||
{{ playlist.name }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-3 mt-1 text-xs text-text-tertiary">
|
||||
<span v-if="playlist.trackCount != null" class="flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
|
||||
{{ callLocale('songCount', `${playlist.trackCount} 首`, playlist.trackCount) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="playlist.creator?.nickname"
|
||||
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-text-disabled group-hover:text-text-tertiary 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-text-tertiary"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-bg-tertiary-50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="music" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="font-medium">{{ locale.emptyPlaylist }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="song in songs"
|
||||
v-else
|
||||
:key="song.id"
|
||||
class="group flex items-center p-4 bg-bg-tertiary-30 border border-border-secondary-50 rounded-3xl hover:bg-bg-tertiary-50 hover:border-border-tertiary transition-all"
|
||||
>
|
||||
<!-- 封面与播放叠加层 -->
|
||||
<div
|
||||
class="relative w-14 h-14 rounded-2xl overflow-hidden bg-bg-tertiary 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-bg-primary-40 flex items-center justify-center opacity-0 group-hover/cover:opacity-100 transition-opacity"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-bg-secondary-20 backdrop-blur-md flex items-center justify-center"
|
||||
>
|
||||
<Icon name="play" :size="16" class="text-text-primary fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-text-primary truncate group-hover:text-text-primary transition-colors"
|
||||
>
|
||||
{{ song.name }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2 mt-0.5 text-xs text-text-tertiary 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-text-disabled animate-pulse"
|
||||
>
|
||||
{{ locale.processing }}
|
||||
</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-error-10 text-error text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
{{ requestLocale.played }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="getSimilarSong(song)?.scheduled"
|
||||
class="px-2 py-0.5 rounded-md bg-warning-10 text-warning text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
{{ requestLocale.scheduled }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-0.5 rounded-md bg-bg-quaternary-50 text-text-tertiary text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
{{ locale.exists }}
|
||||
</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="getSimilarSong(song)?.played && isSuperAdmin"
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-xs font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click.stop="selectSong(song)"
|
||||
>
|
||||
{{ submitting && selectedSongId === song.id ? '...' : requestLocale.continueSubmit }}
|
||||
</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-error-10 text-error border border-error-20'
|
||||
: 'bg-bg-tertiary text-text-tertiary hover:bg-bg-quaternary hover:text-text-primary border border-border-tertiary-50 hover:border-border-tertiary'
|
||||
]"
|
||||
:disabled="
|
||||
getSimilarSong(song)?.played ||
|
||||
getSimilarSong(song)?.scheduled ||
|
||||
getSimilarSong(song)?.voted ||
|
||||
submitting
|
||||
"
|
||||
@click.stop="handleLike(getSimilarSong(song))"
|
||||
>
|
||||
{{ getSimilarSong(song)?.voted ? requestLocale.liked : requestLocale.like }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
:disabled="submitting || songsLoadingForSimilar"
|
||||
class="px-4 py-2 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-[var(--primary-glow)]"
|
||||
@click.stop="selectSong(song)"
|
||||
>
|
||||
{{ submitting && selectedSongId === song.id ? requestLocale.submitting : requestLocale.chooseSubmit }}
|
||||
</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-bg-tertiary hover:bg-bg-quaternary text-text-secondary 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 ? locale.loadingMore : locale.loadMore }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getPlaylistTracks, getUserPlaylists } from '~/utils/neteaseApi'
|
||||
import {
|
||||
getQqPlaylistSongs,
|
||||
getQqUserPlaylists,
|
||||
QQ_FAV_PLAYLIST_ID
|
||||
} from '~/utils/qqUserLibrary'
|
||||
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'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { normalizeForMatch as normalizeString } from '~/utils/song-name-normalize'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
cookie: String,
|
||||
uid: [String, Number],
|
||||
platform: { type: String, default: 'netease' }
|
||||
})
|
||||
|
||||
const { songs: songsLocale } = useLocale()
|
||||
const requestLocale = computed(() => songsLocale.value?.requestForm || {})
|
||||
const locale = computed(() => requestLocale.value?.playlistModal || {})
|
||||
const { t: callLocale } = useLocaleText(locale)
|
||||
|
||||
const emit = defineEmits(['close', 'submit', 'play'])
|
||||
|
||||
const isTencentPlatform = computed(() => props.platform === 'tencent')
|
||||
|
||||
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 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 (isTencentPlatform.value ? !props.cookie : (!props.cookie || !props.uid)) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
if (isTencentPlatform.value) {
|
||||
const list = await getQqUserPlaylists(props.cookie)
|
||||
// 首部固定注入"我喜欢"入口,歌曲经 playlist-songs 专用链路获取
|
||||
list.unshift({
|
||||
id: QQ_FAV_PLAYLIST_ID,
|
||||
name: locale.value.favSongs || 'Liked Songs',
|
||||
cover: '',
|
||||
count: null,
|
||||
origin: 'liked'
|
||||
})
|
||||
if (list.length > 0) {
|
||||
// 补齐歌单卡片模板所需字段
|
||||
playlists.value = list.map((item) => ({
|
||||
...item,
|
||||
coverImgUrl: item.cover,
|
||||
trackCount: item.count
|
||||
}))
|
||||
} else {
|
||||
error.value = locale.value.fetchPlaylistsFailed
|
||||
}
|
||||
} else {
|
||||
const { code, body, message } = await getUserPlaylists(props.uid, props.cookie)
|
||||
if (code === 200 && body && body.playlist) {
|
||||
playlists.value = body.playlist
|
||||
} else {
|
||||
error.value = message || locale.value.fetchPlaylistsFailed
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = locale.value.networkFailed
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPlaylistSongs = async (playlistId, isLoadMore = false) => {
|
||||
if (isTencentPlatform.value ? !props.cookie : !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 变量应该跟踪目前为止加载的歌曲数量。
|
||||
|
||||
if (isTencentPlatform.value) {
|
||||
const result = await getQqPlaylistSongs(playlistId, props.cookie, limit.value, offset.value)
|
||||
if (isLoadMore) {
|
||||
songs.value = [...songs.value, ...result.songs]
|
||||
} else {
|
||||
songs.value = result.songs
|
||||
}
|
||||
|
||||
offset.value += result.songs.length
|
||||
hasMore.value = result.hasMore
|
||||
} else {
|
||||
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 || locale.value.fetchSongsFailed
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = locale.value.networkFailed
|
||||
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
|
||||
? (requestLocale.value.playedCannotLike || '已播放的歌曲不能点赞')
|
||||
: (requestLocale.value.scheduledCannotLike || '已排期的歌曲不能点赞')
|
||||
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,
|
||||
albumId: songData.al?.id,
|
||||
musicPlatform: isTencentPlatform.value ? 'tencent' : 'netease',
|
||||
musicId: songData.id.toString(),
|
||||
sourceInfo: {
|
||||
source: isTencentPlatform.value ? 'tencent-library' : 'netease-backup',
|
||||
playlistId: selectedPlaylist.value?.id,
|
||||
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,
|
||||
albumId: songData.al?.id,
|
||||
duration: songData.dt,
|
||||
musicPlatform: isTencentPlatform.value ? 'tencent' : 'netease',
|
||||
musicId: songData.id.toString(),
|
||||
sourceInfo: {
|
||||
source: isTencentPlatform.value ? 'tencent-library' : 'netease-backup',
|
||||
playlistId: selectedPlaylist.value?.id,
|
||||
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: var(--overlay-10);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--overlay-20);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,481 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-2xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-8 pb-4">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-primary-hover-10 flex items-center justify-center text-primary flex-shrink-0"
|
||||
>
|
||||
<Icon name="mic" :size="24" />
|
||||
</div>
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight truncate">
|
||||
{{ formatLocale(locale.programListTitle, radioName) }}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all flex-shrink-0"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主体 -->
|
||||
<div class="flex-1 overflow-y-auto p-8 pt-4 custom-scrollbar">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex flex-col items-center justify-center py-20 text-text-tertiary"
|
||||
>
|
||||
<Icon name="refresh" :size="48" class="animate-spin mb-4 text-primary" />
|
||||
<p class="font-black uppercase tracking-widest text-[10px]">{{ locale.loadingPrograms }}</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-error-10 flex items-center justify-center text-error mb-4"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="32" />
|
||||
</div>
|
||||
<p class="text-sm text-text-tertiary mb-6">{{ error }}</p>
|
||||
<button
|
||||
class="px-8 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-primary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="fetchPrograms(false)"
|
||||
>
|
||||
{{ locale.retry }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="programs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-text-tertiary"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-bg-tertiary-50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="mic" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">{{ locale.noPrograms }}</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-bg-tertiary-30 border border-border-secondary-50 rounded-3xl hover:bg-bg-tertiary-50 hover:border-border-tertiary transition-all"
|
||||
>
|
||||
<!-- 封面与播放叠加层 -->
|
||||
<div
|
||||
class="relative w-14 h-14 rounded-2xl overflow-hidden bg-bg-tertiary 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-bg-primary-40 flex items-center justify-center opacity-0 group-hover/cover:opacity-100 transition-opacity"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-bg-secondary-20 backdrop-blur-md flex items-center justify-center"
|
||||
>
|
||||
<Icon name="play" :size="16" class="text-text-primary fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 节目信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-text-primary truncate group-hover:text-text-primary transition-colors"
|
||||
>
|
||||
{{ program.name }}
|
||||
</h4>
|
||||
<div
|
||||
class="flex items-center gap-3 mt-1 text-[10px] text-text-tertiary font-bold uppercase tracking-wider"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{{ 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-text-disabled animate-pulse uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.processing }}
|
||||
</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-error-10 text-error text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
{{ locale.played }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="getSimilarSong(program)?.scheduled"
|
||||
class="px-2 py-0.5 rounded-md bg-warning-10 text-warning text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
{{ locale.scheduled }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-0.5 rounded-md bg-bg-quaternary-50 text-text-tertiary text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
{{ locale.existing }}
|
||||
</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="getSimilarSong(program)?.played && isSuperAdmin"
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-[10px] font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click.stop="selectProgram(program)"
|
||||
>
|
||||
{{ submitting && selectedProgramId === program.id ? '...' : locale.continueSubmit }}
|
||||
</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-error-10 text-error border border-error-20'
|
||||
: 'bg-bg-tertiary text-text-tertiary hover:bg-bg-quaternary hover:text-text-primary border border-border-tertiary-50 hover:border-border-tertiary'
|
||||
]"
|
||||
:disabled="
|
||||
getSimilarSong(program)?.played ||
|
||||
getSimilarSong(program)?.scheduled ||
|
||||
getSimilarSong(program)?.voted ||
|
||||
submitting
|
||||
"
|
||||
@click.stop="handleLikeFromProgram(getSimilarSong(program))"
|
||||
>
|
||||
{{ getSimilarSong(program)?.voted ? locale.liked : locale.like }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
:disabled="submitting"
|
||||
class="px-4 py-2 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-[var(--primary-glow)]"
|
||||
@click="selectProgram(program)"
|
||||
>
|
||||
{{ submitting && selectedProgramId === program.id ? locale.submitLoading : locale.selectSubmit }}
|
||||
</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-bg-tertiary hover:bg-bg-quaternary text-text-secondary 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 ? locale.loadingMore : locale.loadMore }}
|
||||
</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 { useLocale } from '~/utils/locale'
|
||||
const { songs: songsLocale } = useLocale()
|
||||
const locale = computed(() => songsLocale.value?.mediaModals || {})
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { normalizeForMatch as normalizeString } from '~/utils/song-name-normalize'
|
||||
|
||||
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 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 || locale.value.fetchProgramsFailed
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = locale.value.networkFailed
|
||||
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 || locale.value.unknownDj,
|
||||
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
|
||||
? (locale.value.playedCannotLike || '已播放的歌曲不能点赞')
|
||||
: (locale.value.scheduledCannotLike || '已排期的歌曲不能点赞')
|
||||
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) + locale.value.tenThousand
|
||||
}
|
||||
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: var(--overlay-10);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--overlay-20);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,371 @@
|
||||
<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-bg-primary-60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-sm overflow-hidden rounded-3xl border border-border-secondary bg-bg-secondary shadow-2xl"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border-secondary-50 p-8 pb-4">
|
||||
<div>
|
||||
<h3 class="flex items-center gap-3 text-xl font-black tracking-tight text-text-primary">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-2xl bg-info-10 text-info"
|
||||
>
|
||||
<Icon name="music" :size="20" />
|
||||
</div>
|
||||
{{ locale.qqTitle }}
|
||||
</h3>
|
||||
<p class="ml-13 mt-1 text-xs text-text-tertiary">{{ locale.qqDesc }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-2xl bg-bg-tertiary-50 p-3 text-text-tertiary transition-all hover:bg-bg-tertiary hover:text-text-primary"
|
||||
type="button"
|
||||
@click="handleClose"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center p-8 pt-4">
|
||||
<!-- 登录渠道 Tab -->
|
||||
<div
|
||||
v-if="!status || status !== 'success'"
|
||||
class="mb-5 grid w-full grid-cols-2 gap-1 rounded-2xl bg-bg-tertiary-50 p-1"
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'rounded-xl px-3 py-2 text-[11px] font-black uppercase tracking-widest transition-all',
|
||||
channel === 'qq'
|
||||
? 'bg-bg-secondary text-text-primary shadow'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
]"
|
||||
type="button"
|
||||
@click="switchChannel('qq')"
|
||||
>
|
||||
{{ locale.channelQq }}
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'rounded-xl px-3 py-2 text-[11px] font-black uppercase tracking-widest transition-all',
|
||||
channel === 'wx'
|
||||
? 'bg-bg-secondary text-text-primary shadow'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
]"
|
||||
type="button"
|
||||
@click="switchChannel('wx')"
|
||||
>
|
||||
{{ locale.channelWx }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-[250px] w-full flex-col items-center justify-center">
|
||||
<div v-if="loading" class="flex flex-col items-center text-text-tertiary">
|
||||
<AppSpinner :size="48" class="mb-4" />
|
||||
<p class="text-[10px] font-bold uppercase tracking-widest">{{ locale.loadingQr }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="qrImg" class="group relative">
|
||||
<div
|
||||
class="rounded-3xl bg-bg-secondary p-4 shadow-inner transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
>
|
||||
<img
|
||||
:src="qrImg"
|
||||
alt="QQ Music Login QR Code"
|
||||
class="h-44 w-44 rounded-2xl object-contain"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isExpired"
|
||||
class="absolute inset-0 flex cursor-pointer items-center justify-center rounded-3xl bg-bg-secondary-90 backdrop-blur-sm transition-all hover:bg-bg-secondary-80"
|
||||
@click="initLogin"
|
||||
>
|
||||
<div class="flex flex-col items-center text-text-primary">
|
||||
<Icon name="refresh" :size="40" class="mb-3 text-text-tertiary" />
|
||||
<span class="text-xs font-black uppercase tracking-widest">{{ locale.qrExpired }}</span>
|
||||
<span class="mt-1 text-[10px] font-bold text-text-tertiary">{{ locale.clickRefresh }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" class="flex flex-col items-center text-center">
|
||||
<Icon name="alert-circle" :size="44" class="mb-4 text-error" />
|
||||
<p class="text-sm font-bold text-text-primary">{{ errorMessage }}</p>
|
||||
<button
|
||||
class="mt-5 rounded-2xl bg-info px-5 py-2 text-xs font-black text-text-primary transition hover:bg-info"
|
||||
type="button"
|
||||
@click="initLogin"
|
||||
>
|
||||
{{ locale.retry }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 h-6 text-center">
|
||||
<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 === 'expired'"
|
||||
class="text-xs font-black uppercase tracking-widest text-text-tertiary"
|
||||
>
|
||||
{{ locale.expiredRefresh }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="status === 'waiting'"
|
||||
class="text-xs font-black uppercase tracking-widest text-text-tertiary"
|
||||
>
|
||||
{{
|
||||
scanned
|
||||
? locale.wxScanned
|
||||
: channel === 'wx'
|
||||
? locale.wxWaiting
|
||||
: locale.qqWaiting
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="status === 'success'"
|
||||
class="text-xs font-black uppercase tracking-widest text-success"
|
||||
>
|
||||
{{ locale.qqSuccess }}
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 w-full rounded-2xl border border-border-secondary-50 bg-bg-tertiary-30 p-4">
|
||||
<p
|
||||
class="text-center text-[10px] font-black uppercase leading-relaxed tracking-[0.15em] text-text-tertiary"
|
||||
>
|
||||
{{ channel === 'wx' ? locale.wxTip : locale.qqTip }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const { songs } = useLocale()
|
||||
const locale = computed(() => songs.value?.musicLoginModal || {})
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'login-success'])
|
||||
|
||||
const qrImg = ref('')
|
||||
const loading = ref(false)
|
||||
const status = ref('')
|
||||
const isExpired = ref(false)
|
||||
// 微信渠道:已扫码待确认(驱动状态行文案切换)
|
||||
const scanned = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const checking = ref(false)
|
||||
// 登录渠道:qq、wx
|
||||
const channel = ref('qq')
|
||||
// 弹窗开关标记:防止关闭后在飞的长轮询响应继续触发下一轮
|
||||
let isOpen = false
|
||||
let timer = null
|
||||
let qrPayload = null
|
||||
|
||||
const stopPolling = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleNextCheck = () => {
|
||||
if (timer || !isOpen) return
|
||||
// 微信为长轮询接口,收到响应后短间隔重入即可
|
||||
const delay = channel.value === 'wx' ? 800 : 3000
|
||||
timer = setTimeout(async () => {
|
||||
timer = null
|
||||
await checkStatus()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
isOpen = false
|
||||
stopPolling()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const getSessionCookie = (session) => {
|
||||
if (typeof session?.cookie === 'string') return session.cookie
|
||||
if (Array.isArray(session?.cookieList)) return session.cookieList.join('; ')
|
||||
return ''
|
||||
}
|
||||
|
||||
const buildUserInfo = (session, user) => {
|
||||
if (user) return user
|
||||
|
||||
const uin = session?.uin || session?.loginUin || session?.cookieObject?.uin || ''
|
||||
return {
|
||||
userId: uin,
|
||||
id: uin,
|
||||
nickname: uin || locale.value.qqAccount,
|
||||
userName: uin || locale.value.qqAccount,
|
||||
raw: session
|
||||
}
|
||||
}
|
||||
|
||||
const switchChannel = (value) => {
|
||||
if (channel.value === value) return
|
||||
channel.value = value
|
||||
initLogin()
|
||||
}
|
||||
|
||||
const initLogin = async () => {
|
||||
stopPolling()
|
||||
isOpen = true
|
||||
loading.value = true
|
||||
status.value = ''
|
||||
isExpired.value = false
|
||||
scanned.value = false
|
||||
errorMessage.value = ''
|
||||
qrImg.value = ''
|
||||
qrPayload = null
|
||||
|
||||
try {
|
||||
if (channel.value === 'wx') {
|
||||
const response = await $fetch('/api/native-api/qq/login-qr-wx')
|
||||
const data = response?.data || {}
|
||||
if (!response?.success || !data.img || !data.uuid) {
|
||||
throw new Error(locale.value.qqQrIncomplete)
|
||||
}
|
||||
|
||||
qrPayload = data
|
||||
qrImg.value = data.img
|
||||
status.value = 'waiting'
|
||||
scheduleNextCheck()
|
||||
} else {
|
||||
const response = await $fetch('/api/native-api/qq/login-qr')
|
||||
const data = response?.data || {}
|
||||
if (!response?.success || !data.img || !data.ptqrtoken || !data.qrsig) {
|
||||
throw new Error(locale.value.qqQrIncomplete)
|
||||
}
|
||||
|
||||
qrPayload = data
|
||||
qrImg.value = data.img
|
||||
status.value = 'waiting'
|
||||
scheduleNextCheck()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化扫码登录失败:', error)
|
||||
errorMessage.value = error?.message || locale.value.qqQrFailed
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const checkStatus = async () => {
|
||||
if (!qrPayload || !isOpen || checking.value) return
|
||||
|
||||
checking.value = true
|
||||
try {
|
||||
let data
|
||||
if (channel.value === 'wx') {
|
||||
const response = await $fetch('/api/native-api/qq/check-wx-login', {
|
||||
method: 'POST',
|
||||
body: { uuid: qrPayload.uuid }
|
||||
})
|
||||
data = response?.data || {}
|
||||
} else {
|
||||
const response = await $fetch('/api/native-api/qq/check-login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
ptqrtoken: qrPayload.ptqrtoken,
|
||||
qrsig: qrPayload.qrsig
|
||||
}
|
||||
})
|
||||
data = response?.data || {}
|
||||
}
|
||||
const loginStatus = data.status || (data.isOk ? 'success' : data.refresh ? 'expired' : 'waiting')
|
||||
|
||||
// 微信渠道:手机已扫码、等待确认
|
||||
if (data.scanned) {
|
||||
scanned.value = true
|
||||
}
|
||||
|
||||
if (loginStatus === 'success' || data.isOk) {
|
||||
const cookie = data.cookie || getSessionCookie(data.session)
|
||||
if (!cookie) throw new Error(locale.value.qqMissingCookie)
|
||||
|
||||
status.value = 'success'
|
||||
stopPolling()
|
||||
emit('login-success', {
|
||||
cookie,
|
||||
user: buildUserInfo(data.session, data.user),
|
||||
authDiagnostic: data.authDiagnostic
|
||||
})
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (loginStatus === 'expired' || data.refresh) {
|
||||
status.value = 'expired'
|
||||
isExpired.value = true
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
|
||||
status.value = 'waiting'
|
||||
} catch (error) {
|
||||
console.error('检查扫码登录状态失败:', error)
|
||||
errorMessage.value = error?.message || locale.value.qqCheckFailed
|
||||
stopPolling()
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
|
||||
// 仅在未出结果时继续轮询
|
||||
if (!isExpired.value && status.value === 'waiting') {
|
||||
scheduleNextCheck()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
initLogin()
|
||||
} else {
|
||||
isOpen = false
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
isOpen = false
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,448 @@
|
||||
<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-bg-primary-80 backdrop-blur-sm"
|
||||
@click.self="close"
|
||||
>
|
||||
<div
|
||||
class="bg-bg-secondary border border-border-secondary 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-border-secondary-50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-primary-hover-10 flex items-center justify-center text-primary"
|
||||
>
|
||||
<Icon name="history" :size="20" />
|
||||
</div>
|
||||
{{ locale.recentTitle }}
|
||||
</h3>
|
||||
<p class="text-xs text-text-tertiary mt-1 ml-13">{{ locale.recentDesc }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary 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-text-tertiary"
|
||||
>
|
||||
<Icon name="refresh" :size="32" class="animate-spin mb-4 text-primary" />
|
||||
<div class="text-[10px] font-black uppercase tracking-widest">
|
||||
{{ locale.loadingRecent }}
|
||||
</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-error-10 flex items-center justify-center text-error mb-4"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="32" />
|
||||
</div>
|
||||
<p class="text-sm text-text-tertiary mb-6">{{ error }}</p>
|
||||
<button
|
||||
class="px-6 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-primary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="fetchRecentSongs"
|
||||
>
|
||||
{{ locale.retryLoad }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="songs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-20 text-text-disabled"
|
||||
>
|
||||
<Icon name="music" :size="48" class="mb-4 opacity-20" />
|
||||
<p class="text-sm font-bold uppercase tracking-widest">{{ locale.noRecent }}</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-bg-tertiary-50 transition-all border border-transparent hover:border-border-secondary"
|
||||
>
|
||||
<div
|
||||
class="relative w-14 h-14 rounded-xl overflow-hidden bg-bg-tertiary 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-bg-primary-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-bg-secondary-20 backdrop-blur-md flex items-center justify-center text-text-primary border border-primary-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-text-primary truncate mb-1"
|
||||
>
|
||||
{{ item.data?.name }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[10px] text-text-tertiary font-medium whitespace-nowrap">{{
|
||||
formatTime(item.playTime)
|
||||
}}</span>
|
||||
<span
|
||||
class="text-[10px] text-text-disabled 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-bg-tertiary-50 rounded-lg"
|
||||
>
|
||||
<Icon name="refresh" :size="10" class="animate-spin text-text-tertiary" />
|
||||
<span class="text-[10px] text-text-tertiary font-black uppercase">{{ locale.checking }}</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-success-10 text-success border-success-20'
|
||||
: getSimilarSong(item.data)?.scheduled
|
||||
? 'bg-warning-10 text-warning border-warning-20'
|
||||
: 'bg-bg-tertiary text-text-tertiary border-border-tertiary'
|
||||
]"
|
||||
>
|
||||
{{
|
||||
getSimilarSong(item.data)?.played
|
||||
? locale.played
|
||||
: getSimilarSong(item.data)?.scheduled
|
||||
? locale.scheduled
|
||||
: locale.inList
|
||||
}}
|
||||
</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-primary-hover hover:bg-primary text-text-primary text-[10px] font-black rounded-lg transition-all uppercase tracking-widest disabled:opacity-50"
|
||||
@click="selectSong(item.data)"
|
||||
>
|
||||
{{
|
||||
submitting && selectedSongId === item.data?.id ? locale.processing : locale.continueSubmit
|
||||
}}
|
||||
</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-bg-tertiary hover:bg-bg-quaternary disabled:bg-bg-secondary-50 text-text-tertiary disabled:text-text-secondary 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-error fill-current' : ''
|
||||
]"
|
||||
/>
|
||||
{{ getSimilarSong(item.data)?.voted ? locale.liked : locale.like }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
:disabled="submitting || songsLoadingForSimilar"
|
||||
class="px-4 py-2 bg-primary-hover hover:bg-primary text-text-primary text-[10px] font-black rounded-xl transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50 shadow-lg shadow-[var(--primary-glow)]"
|
||||
@click="selectSong(item.data)"
|
||||
>
|
||||
{{
|
||||
submitting && selectedSongId === item.data?.id ? locale.submitting : locale.selectSubmit
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部栏 -->
|
||||
<div
|
||||
class="p-6 border-t border-border-secondary-50 bg-bg-secondary-50 flex items-center justify-end"
|
||||
>
|
||||
<button
|
||||
class="px-6 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="close"
|
||||
>
|
||||
{{ locale.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 { useLocale } from '~/utils/locale'
|
||||
import { normalizeForMatch as normalizeString } from '~/utils/song-name-normalize'
|
||||
import { useSongs } from '~/composables/useSongs'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useSemesters } from '~/composables/useSemesters'
|
||||
|
||||
const { songs: songsLocale } = useLocale()
|
||||
const locale = computed(() => {
|
||||
const base = songsLocale.value?.mediaModals || {}
|
||||
const emptyText = () => ''
|
||||
return useSafeLocale({
|
||||
...base,
|
||||
minutesAgo: base.minutesAgo || emptyText,
|
||||
hoursAgo: base.hoursAgo || emptyText,
|
||||
monthDay: base.monthDay || emptyText
|
||||
})
|
||||
})
|
||||
|
||||
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 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 = getSyncedDate()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) return locale.value.justNow
|
||||
if (diff < 3600000) return formatLocaleValue(locale.value.minutesAgo, Math.floor(diff / 60000))
|
||||
if (diff < 86400000) return formatLocaleValue(locale.value.hoursAgo, Math.floor(diff / 3600000))
|
||||
|
||||
return formatLocaleValue(locale.value.monthDay, 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 || locale.value.fetchRecentFailed
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = locale.value.networkFailed
|
||||
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
|
||||
? (locale.value.playedCannotLike || '已播放的歌曲不能点赞')
|
||||
: (locale.value.scheduledCannotLike || '已排期的歌曲不能点赞')
|
||||
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,
|
||||
albumId: songData.al?.id,
|
||||
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,
|
||||
albumId: songData.al?.id,
|
||||
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: var(--text-muted);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user