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>