Files
BYW-VoiceHub/VoiceHub-main/app/components/Songs/RecentSongsModal.vue
T
Henry_Du 4ce4649aaf
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s
初次提交
2026-09-06 08:19:02 +08:00

452 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-[2000] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
@click.self="close"
>
<div
class="bg-zinc-900 border border-zinc-800 w-full max-w-2xl rounded-3xl overflow-hidden shadow-2xl flex flex-col max-h-[85vh]"
>
<!-- 头部 -->
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
<div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
<div
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
>
<Icon name="history" :size="20" />
</div>
最近播放 - 歌曲
</h3>
<p class="text-xs text-zinc-500 mt-1 ml-13">查看并快速投稿您最近在网易云播放的歌曲</p>
</div>
<button
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
@click="close"
>
<Icon name="x" :size="20" />
</button>
</div>
<!-- 主体 -->
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar">
<div
v-if="loading"
class="flex flex-col items-center justify-center py-20 text-zinc-500"
>
<Icon name="refresh" :size="32" class="animate-spin mb-4 text-blue-500" />
<div class="text-[10px] font-black uppercase tracking-widest">
正在获取播放记录...
</div>
</div>
<div
v-else-if="error"
class="flex flex-col items-center justify-center py-20 text-center px-8"
>
<div
class="w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center text-red-500 mb-4"
>
<Icon name="alert-triangle" :size="32" />
</div>
<p class="text-sm text-zinc-400 mb-6">{{ error }}</p>
<button
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
@click="fetchRecentSongs"
>
重试加载
</button>
</div>
<div
v-else-if="songs.length === 0"
class="flex flex-col items-center justify-center py-20 text-zinc-600"
>
<Icon name="music" :size="48" class="mb-4 opacity-20" />
<p class="text-sm font-bold uppercase tracking-widest">暂无最近播放记录</p>
</div>
<div v-else class="recent-song-list space-y-2">
<div
v-for="item in songs"
:key="item.resourceId"
class="group flex items-center gap-4 p-3 rounded-2xl hover:bg-zinc-800/50 transition-all border border-transparent hover:border-zinc-800"
>
<div
class="relative w-14 h-14 rounded-xl overflow-hidden bg-zinc-800 flex-shrink-0 shadow-lg group-hover:scale-105 transition-transform duration-300"
>
<img
:src="convertToHttps(item.data?.al?.picUrl)"
alt="cover"
class="w-full h-full object-cover"
loading="lazy"
>
<div
class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
@click.stop="playSong(item.data)"
>
<div
class="w-8 h-8 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center text-white border border-white/30"
>
<Icon name="play" :size="14" class="fill-current" />
</div>
</div>
</div>
<div class="flex-1 min-w-0">
<h4
:title="item.data?.name"
class="text-sm font-black text-zinc-100 truncate mb-1"
>
{{ item.data?.name }}
</h4>
<div class="flex items-center gap-3">
<span class="text-[10px] text-zinc-500 font-medium whitespace-nowrap">{{
formatTime(item.playTime)
}}</span>
<span
class="text-[10px] text-zinc-600 truncate font-bold uppercase tracking-tighter"
>{{ item.data?.ar?.map((a) => a.name).join('/') }}</span
>
</div>
</div>
<div class="flex flex-col items-end gap-2">
<div
v-if="songsLoadingForSimilar"
class="flex items-center gap-2 px-3 py-1 bg-zinc-800/50 rounded-lg"
>
<Icon name="refresh" :size="10" class="animate-spin text-zinc-500" />
<span class="text-[10px] text-zinc-500 font-black uppercase">检查中</span>
</div>
<template v-else-if="getSimilarSong(item.data)">
<div
:class="[
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-tighter border',
getSimilarSong(item.data)?.played
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: getSimilarSong(item.data)?.scheduled
? 'bg-amber-500/10 text-amber-500 border-amber-500/20'
: 'bg-zinc-800 text-zinc-500 border-zinc-700'
]"
>
{{
getSimilarSong(item.data)?.played
? '已播放'
: getSimilarSong(item.data)?.scheduled
? '已排期'
: '已在列表中'
}}
</div>
<div class="flex items-center gap-2">
<button
v-if="getSimilarSong(item.data)?.played && isSuperAdmin"
:disabled="submitting"
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black rounded-lg transition-all uppercase tracking-widest disabled:opacity-50"
@click="selectSong(item.data)"
>
{{
submitting && selectedSongId === item.data?.id ? '处理中...' : '继续投稿'
}}
</button>
<button
v-else
:disabled="
getSimilarSong(item.data)?.played ||
getSimilarSong(item.data)?.scheduled ||
getSimilarSong(item.data)?.voted ||
submitting
"
class="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 disabled:bg-zinc-900/50 text-zinc-400 disabled:text-zinc-700 text-[10px] font-black rounded-lg transition-all uppercase tracking-widest flex items-center gap-1.5"
@click="
getSimilarSong(item.data)?.played || getSimilarSong(item.data)?.scheduled
? null
: handleLike(getSimilarSong(item.data))
"
>
<Icon
name="heart"
:size="10"
:class="[
getSimilarSong(item.data)?.voted ? 'text-red-500 fill-current' : ''
]"
/>
{{ getSimilarSong(item.data)?.voted ? '已点赞' : '点赞' }}
</button>
</div>
</template>
<button
v-else
:disabled="submitting || songsLoadingForSimilar"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black rounded-xl transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50 shadow-lg shadow-blue-900/20"
@click="selectSong(item.data)"
>
{{
submitting && selectedSongId === item.data?.id ? '正在提交...' : '选择投稿'
}}
</button>
</div>
</div>
</div>
</div>
<!-- 底部栏 -->
<div
class="p-6 border-t border-zinc-800/50 bg-zinc-900/50 flex items-center justify-between"
>
<div
class="flex items-center gap-2 text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>
<Icon name="info" :size="12" />
数据来自网易云音乐播放记录
</div>
<button
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
@click="close"
>
关闭窗口
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { getRecentSongs } from '~/utils/neteaseApi'
import { convertToHttps } from '~/utils/url'
import Icon from '~/components/UI/Icon.vue'
import { useSongs } from '~/composables/useSongs'
import { useAuth } from '~/composables/useAuth'
import { useSemesters } from '~/composables/useSemesters'
const props = defineProps({
show: Boolean,
cookie: String
})
const emit = defineEmits(['close', 'submit', 'play'])
const songs = ref([])
const loading = ref(false)
const songsLoadingForSimilar = ref(false)
const error = ref('')
const submitting = ref(false)
const selectedSongId = ref(null)
const songService = useSongs()
const auth = useAuth()
const { currentSemester, fetchCurrentSemester } = useSemesters()
const isSuperAdmin = computed(() => auth.user.value?.role === 'SUPER_ADMIN')
// 标准化字符串
const normalizeString = (str) => {
if (!str) return ''
return str
.toLowerCase()
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉""''""''、,。!?:;~·]/g, '')
.replace(/[&&]/g, 'and')
.replace(/[feat\.?|ft\.?]/gi, '')
.trim()
}
// 检查是否已存在相似歌曲
const getSimilarSong = (songData) => {
if (!songData) return null
const title = songData.name
const artist = songData.ar?.map((a) => a.name).join('/')
if (!title || !artist) return null
const normalizedTitle = normalizeString(title)
const normalizedArtist = normalizeString(artist)
const currentSemesterName = currentSemester.value?.name
// 如果没有获取到当前学期信息,暂时不进行检查,避免误报
if (!currentSemesterName) return null
return songService.songs.value.find((song) => {
const songTitle = normalizeString(song.title)
const songArtist = normalizeString(song.artist)
const titleMatch = songTitle === normalizedTitle && songArtist === normalizedArtist
// 必须匹配当前学期
return titleMatch && song.semester === currentSemesterName
})
}
const formatTime = (timestamp) => {
if (!timestamp) return ''
const date = new Date(timestamp)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
return `${date.getMonth() + 1}月${date.getDate()}日`
}
const fetchRecentSongs = async () => {
if (!props.cookie) return
loading.value = true
error.value = ''
try {
const { code, body, message } = await getRecentSongs(100, props.cookie)
if (code === 200 && body && body.list) {
songs.value = body.list.filter((item) => item.resourceType === 'SONG' && item.data)
} else {
error.value = message || '获取最近播放失败'
}
} catch (err) {
error.value = '网络请求失败'
console.error(err)
} finally {
loading.value = false
}
}
watch(
() => props.show,
async (newVal) => {
if (newVal) {
// 确保当前学期已加载,用于正确检查歌曲状态
if (!currentSemester.value) {
await fetchCurrentSemester()
}
fetchRecentSongs()
// 加载歌曲列表以便检查相似歌曲
if (
auth.isAuthenticated.value &&
(!songService.songs.value || songService.songs.value.length === 0)
) {
songsLoadingForSimilar.value = true
const currentSemesterName = currentSemester.value?.name
songService
.fetchSongs(true, currentSemesterName)
.catch((err) => {
console.error('加载歌曲列表失败:', err)
})
.finally(() => {
songsLoadingForSimilar.value = false
})
}
} else {
songs.value = []
submitting.value = false
selectedSongId.value = null
}
}
)
const handleLike = async (song) => {
if (!song || song.voted) {
return
}
if (song.played || song.scheduled) {
if (window.$showNotification) {
const message = song.played ? '已播放的歌曲不能点赞' : '已排期的歌曲不能点赞'
window.$showNotification(message, 'warning')
}
return
}
try {
await songService.voteSong(song.id)
} catch (error) {
console.error('点赞失败:', error)
}
}
const playSong = (songData) => {
// 简单的播放预览,如果需要完整播放器支持可能需要更多字段
// 这里暂时只发射事件,由父组件决定如何播放
// 实际上 RequestForm 可能会调用全局播放器
// 但这里为了简化,我们暂时构造一个简单的对象
emit('play', {
id: songData.id,
title: songData.name,
artist: songData.ar?.map((a) => a.name).join('/'),
cover: songData.al?.picUrl,
musicPlatform: 'netease',
musicId: songData.id.toString(),
sourceInfo: {
source: 'netease-backup',
type: 'song'
}
})
}
const selectSong = (songData) => {
selectedSongId.value = songData.id
submitting.value = true
const song = {
id: songData.id,
title: songData.name,
artist: songData.ar?.map((a) => a.name).join('/'),
cover: songData.al?.picUrl,
album: songData.al?.name,
duration: songData.dt,
musicPlatform: 'netease',
musicId: songData.id.toString(),
sourceInfo: {
source: 'netease-backup',
type: 'song'
}
}
emit('submit', song)
// 不立即关闭,由父组件控制
}
const close = () => {
emit('close')
}
defineExpose({
resetSubmissionState: () => {
submitting.value = false
selectedSongId.value = null
}
})
</script>
<style scoped>
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #4a5568;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #718096;
}
</style>