feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+666
View File
@@ -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>
+166
View File
@@ -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>
+487
View File
@@ -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>
+269
View File
@@ -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&timestamp=${Date.now()}&ua=pc`
)
const qrData = await qrRes.json()
qrImg.value = qrData.data.qrimg
status.value = 801
// 启动轮询
timer = setInterval(checkStatus, 3000)
} catch (err) {
console.error('初始化登录失败:', err)
status.value = 0
} finally {
loading.value = false
}
}
const checkStatus = async () => {
if (!unikey) return
try {
const res = await fetch(
`${BASE_URL}/login/qr/check?key=${unikey}&timestamp=${Date.now()}&ua=pc`
)
const data = await res.json()
status.value = data.code
if (data.code === 800) {
// 已过期
isExpired.value = true
stopPolling()
} else if (data.code === 803) {
// 登录成功
stopPolling()
const cookie = data.cookie
await handleLoginSuccess(cookie)
}
} catch (err) {
console.error('检查二维码状态失败:', err)
}
}
const handleLoginSuccess = async (cookie: string) => {
try {
// 通过登录凭证获取用户信息
const userRes = await fetch(`${BASE_URL}/login/status?timestamp=${Date.now()}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ cookie })
})
const res = await userRes.json()
const userInfo = {
cookie,
user: {
userId: res.data?.profile?.userId,
id: res.data?.profile?.userId,
nickname: res.data?.profile?.nickname,
avatarUrl: res.data?.profile?.avatarUrl,
userName: res.data?.profile?.nickname
}
}
emit('login-success', userInfo)
handleClose()
} catch (err) {
console.error('获取用户信息失败:', err)
emit('login-success', { cookie, user: null })
handleClose()
}
}
// 监听显示属性以初始化/停止
watch(
() => props.show,
(newVal) => {
if (newVal) {
initLogin()
} else {
stopPolling()
}
}
)
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped></style>
@@ -0,0 +1,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>
+371
View File
@@ -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>
+448
View File
@@ -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