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
+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>