feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
import { ref } from 'vue'
|
||||
import { useAuth } from './useAuth'
|
||||
import { useServerErrors } from './useLocaleText'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import type { PlayTime, SystemSettings } from '~/types'
|
||||
|
||||
export const useAdmin = () => {
|
||||
const { getAuthConfig, isAdmin } = useAuth()
|
||||
const { composableErrors } = useLocale()
|
||||
const { localize: localizeServerError } = useServerErrors()
|
||||
const action = (key: keyof typeof composableErrors.value.actions) =>
|
||||
composableErrors.value.actions[key]
|
||||
const adminOnly = (key: keyof typeof composableErrors.value.actions) =>
|
||||
composableErrors.value.adminOnly(action(key))
|
||||
const failed = (key: keyof typeof composableErrors.value.actions) =>
|
||||
composableErrors.value.failed(action(key))
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 创建歌曲排期
|
||||
const createSchedule = async (
|
||||
songId: number,
|
||||
playDate: Date,
|
||||
playTimeId?: number | null,
|
||||
sequence: number = 1
|
||||
) => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('createSchedule')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/admin/schedule', {
|
||||
method: 'POST',
|
||||
body: { songId, playDate, playTimeId, sequence },
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('createSchedule')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 移除排期
|
||||
const removeSchedule = async (scheduleId: number) => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('removeSchedule')
|
||||
return { success: false, message: error.value } as const
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const response = await $fetch<{
|
||||
success: boolean
|
||||
message?: string
|
||||
schedule?: any
|
||||
}>('/api/admin/schedule/remove', {
|
||||
method: 'POST',
|
||||
body: { scheduleId },
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 检查响应
|
||||
if (!response.success) {
|
||||
error.value = response.message || failed('removeSchedule')
|
||||
return { success: false, message: error.value } as const
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.data?.message || err.message || failed('removeSchedule')
|
||||
error.value = errorMessage
|
||||
console.error('移除排期错误:', err)
|
||||
return { success: false, message: errorMessage } as const
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新排期顺序
|
||||
const updateScheduleSequence = async (schedules: Array<{ id: number; sequence: number }>) => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('updateScheduleSequence')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/admin/schedule/sequence', {
|
||||
method: 'POST',
|
||||
body: { schedules },
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('updateScheduleSequence')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 标记歌曲已播放
|
||||
const markSongAsPlayed = async (songId: number) => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('markPlayed')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/admin/songs/mark-played', {
|
||||
method: 'POST',
|
||||
body: { songId },
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('markPlayed')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 发送管理员通知
|
||||
const sendAdminNotification = async (notificationData: {
|
||||
title: string
|
||||
content: string
|
||||
important?: boolean
|
||||
scope: 'ALL' | 'GRADE' | 'CLASS' | 'MULTI_CLASS' | 'SPECIFIC_USERS'
|
||||
filter: {
|
||||
grade?: string
|
||||
class?: string
|
||||
classes?: Array<{ grade: string; class: string }>
|
||||
userIds?: number[]
|
||||
}
|
||||
}) => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('sendNotification')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/admin/notifications/send', {
|
||||
method: 'POST',
|
||||
body: notificationData,
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = localizeServerError(err, failed('sendNotification'))
|
||||
throw new Error(error.value, { cause: err })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统设置
|
||||
const getSystemSettings = async () => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('getSystemSettings')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/admin/system-settings', {
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data as SystemSettings
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('getSystemSettings')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新系统设置
|
||||
const updateSystemSettings = async (settings: Partial<SystemSettings>) => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('updateSystemSettings')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/admin/system-settings', {
|
||||
method: 'POST',
|
||||
body: settings,
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data as SystemSettings
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('updateSystemSettings')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有播放时段
|
||||
const getPlayTimes = async () => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('getPlayTimes')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/admin/play-times', {
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data as PlayTime[]
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('getPlayTimes')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建播放时段
|
||||
const createPlayTime = async (playTimeData: Partial<PlayTime>) => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('createPlayTime')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/admin/play-times', {
|
||||
method: 'POST',
|
||||
body: playTimeData,
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data as PlayTime
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('createPlayTime')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新播放时段
|
||||
const updatePlayTime = async (id: number, playTimeData: Partial<PlayTime>) => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('updatePlayTime')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch(`/api/admin/play-times/${id}`, {
|
||||
method: 'PUT',
|
||||
body: playTimeData,
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data as PlayTime
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('updatePlayTime')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除播放时段
|
||||
const deletePlayTime = async (id: number) => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('deletePlayTime')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch(`/api/admin/play-times/${id}`, {
|
||||
method: 'DELETE',
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('deletePlayTime')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除歌曲
|
||||
const deleteSong = async (songId: number) => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('deleteSong')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/admin/songs/delete', {
|
||||
method: 'POST',
|
||||
body: { songId },
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('deleteSong')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新歌曲
|
||||
const updateSong = async (songId: number, songData: any) => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('updateSong')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch(`/api/songs/${songId}/update`, {
|
||||
method: 'PUT',
|
||||
body: songData,
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('updateSong')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加歌曲
|
||||
const addSong = async (songData: any) => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = adminOnly('addSong')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/songs/add', {
|
||||
method: 'POST',
|
||||
body: songData,
|
||||
...authConfig
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('addSong')
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
createSchedule,
|
||||
removeSchedule,
|
||||
markSongAsPlayed,
|
||||
updateScheduleSequence,
|
||||
sendAdminNotification,
|
||||
getSystemSettings,
|
||||
updateSystemSettings,
|
||||
getPlayTimes,
|
||||
createPlayTime,
|
||||
updatePlayTime,
|
||||
deletePlayTime,
|
||||
deleteSong,
|
||||
updateSong,
|
||||
addSong
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
import { isBilibiliSong } from '~/utils/bilibiliSource'
|
||||
import { useMusicSources } from '~/composables/useMusicSources'
|
||||
import { useLyricSettings } from '~/composables/useLyricSettings'
|
||||
import type { MusicTrackMeta } from '~/utils/musicUrl'
|
||||
|
||||
export interface PlayableSong {
|
||||
id: number | string
|
||||
title: string
|
||||
artist: string
|
||||
album?: string | null
|
||||
duration?: number | null
|
||||
musicUrl?: string | null
|
||||
cover?: string | null
|
||||
musicPlatform?: string
|
||||
musicId?: string
|
||||
playUrl?: string | null
|
||||
sourceInfo?: Record<string, any>
|
||||
}
|
||||
|
||||
interface PlaylistItem {
|
||||
id: number | string
|
||||
song: PlayableSong
|
||||
playDate?: string
|
||||
playTimeId?: number
|
||||
}
|
||||
|
||||
// 创建一个单例实例
|
||||
const currentSong = ref<PlayableSong | null>(null)
|
||||
const isPlaying = ref(false)
|
||||
const currentPlaylist = ref<PlaylistItem[]>([])
|
||||
const currentPlaylistIndex = ref(-1)
|
||||
const currentPosition = ref(0) // 当前播放位置(秒)
|
||||
const duration = ref(0) // 歌曲总时长(秒)
|
||||
|
||||
// 预载中的封面图引用,防止加载完成前被回收
|
||||
let pendingCoverImage: HTMLImageElement | null = null
|
||||
|
||||
export function useAudioPlayer() {
|
||||
// 播放歌曲
|
||||
const playSong = (song: PlayableSong, playlist?: PlayableSong[], playlistIndex?: number) => {
|
||||
// 允许没有 musicUrl 但有 musicPlatform+musicId 的歌曲通过(由 loadSong 动态解析)
|
||||
if (!song.musicUrl && !isBilibiliSong(song) && !(song.musicPlatform && song.musicId)) {
|
||||
console.error('歌曲没有可播放的URL')
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果提供了播放列表,更新播放列表
|
||||
if (playlist && playlist.length > 0) {
|
||||
// 将PlayableSong数组转换为PlaylistItem数组
|
||||
currentPlaylist.value = playlist.map((s) => ({ song: s }))
|
||||
// 使用提供的索引或查找当前歌曲在播放列表中的位置
|
||||
if (
|
||||
typeof playlistIndex === 'number' &&
|
||||
playlistIndex >= 0 &&
|
||||
playlistIndex < playlist.length
|
||||
) {
|
||||
currentPlaylistIndex.value = playlistIndex
|
||||
} else {
|
||||
const index = playlist.findIndex((item) => String(item.id) === String(song.id))
|
||||
currentPlaylistIndex.value = index >= 0 ? index : -1
|
||||
}
|
||||
} else if (currentSong.value && String(currentSong.value.id) !== String(song.id)) {
|
||||
// 如果没有提供播放列表且是新歌曲,清空播放列表
|
||||
currentPlaylist.value = []
|
||||
currentPlaylistIndex.value = -1
|
||||
}
|
||||
|
||||
// 如果是同一首歌,则开始播放(不切换状态,由调用方决定)
|
||||
if (currentSong.value && String(currentSong.value.id) === String(song.id)) {
|
||||
// 保持当前歌曲的引用更新,以便获取可能更新的 URL 等信息
|
||||
currentSong.value = song
|
||||
isPlaying.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
// 播放新歌曲
|
||||
currentSong.value = song
|
||||
isPlaying.value = true
|
||||
currentPosition.value = 0
|
||||
duration.value = 0 // 重置时长,等待新歌曲加载后更新
|
||||
return true
|
||||
}
|
||||
|
||||
// 暂停播放
|
||||
const pauseSong = () => {
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
// 停止播放
|
||||
const stopSong = () => {
|
||||
isPlaying.value = false
|
||||
currentSong.value = null
|
||||
currentPosition.value = 0
|
||||
duration.value = 0
|
||||
}
|
||||
|
||||
// 播放下一首歌曲
|
||||
const playNext = async () => {
|
||||
if (currentPlaylist.value.length === 0 || currentPlaylistIndex.value === -1) {
|
||||
console.log('没有播放列表或当前歌曲不在播放列表中')
|
||||
return false
|
||||
}
|
||||
|
||||
let nextIndex = currentPlaylistIndex.value + 1
|
||||
let attempts = 0
|
||||
const maxAttempts = currentPlaylist.value.length // 最多尝试整个播放列表的长度
|
||||
|
||||
// 循环尝试找到可播放的下一首歌曲
|
||||
while (nextIndex < currentPlaylist.value.length && attempts < maxAttempts) {
|
||||
const nextSong = currentPlaylist.value[nextIndex].song
|
||||
|
||||
// 如果已经有播放URL,直接使用
|
||||
if (nextSong.musicUrl) {
|
||||
currentPlaylistIndex.value = nextIndex
|
||||
currentSong.value = nextSong
|
||||
currentPosition.value = 0
|
||||
duration.value = 0 // 重置时长
|
||||
isPlaying.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果没有播放URL,尝试动态获取
|
||||
if (nextSong.musicPlatform && nextSong.musicId) {
|
||||
try {
|
||||
const { getMusicUrl } = await import('~/utils/musicUrl')
|
||||
|
||||
// 检查是否为播客内容
|
||||
const isPodcast =
|
||||
nextSong.musicPlatform === 'netease-podcast' ||
|
||||
nextSong.sourceInfo?.type === 'voice' ||
|
||||
(nextSong.sourceInfo?.source === 'netease-backup' &&
|
||||
nextSong.sourceInfo?.type === 'voice')
|
||||
const options: { unblock?: boolean; musicInfo?: MusicTrackMeta } = isPodcast
|
||||
? { unblock: false }
|
||||
: {}
|
||||
options.musicInfo = {
|
||||
name: nextSong.title,
|
||||
artist: nextSong.artist,
|
||||
album: nextSong.album || undefined
|
||||
}
|
||||
|
||||
const url = await getMusicUrl(
|
||||
nextSong.musicPlatform,
|
||||
nextSong.musicId,
|
||||
nextSong.playUrl,
|
||||
options
|
||||
)
|
||||
if (url) {
|
||||
nextSong.musicUrl = url
|
||||
currentPlaylistIndex.value = nextIndex
|
||||
currentSong.value = nextSong
|
||||
currentPosition.value = 0
|
||||
duration.value = 0 // 重置时长
|
||||
isPlaying.value = true
|
||||
return true
|
||||
} else {
|
||||
const msg = `无法获取《${nextSong.title}》的播放链接,已自动跳过`
|
||||
console.warn(msg)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(msg, 'warning')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = `《${nextSong.title}》获取播放链接失败,已自动跳过`
|
||||
console.warn(msg, error)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(msg, 'error')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const msg = `《${nextSong.title}》缺少播放信息,已自动跳过`
|
||||
console.warn(msg)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(msg, 'warning')
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试下一首
|
||||
nextIndex++
|
||||
attempts++
|
||||
}
|
||||
|
||||
console.log('没有找到可播放的下一首歌曲')
|
||||
return false
|
||||
}
|
||||
|
||||
// 播放上一首歌曲
|
||||
const playPrevious = async () => {
|
||||
if (currentPlaylist.value.length === 0 || currentPlaylistIndex.value === -1) {
|
||||
console.log('没有播放列表或当前歌曲不在播放列表中')
|
||||
return false
|
||||
}
|
||||
|
||||
let prevIndex = currentPlaylistIndex.value - 1
|
||||
let attempts = 0
|
||||
const maxAttempts = currentPlaylist.value.length // 最多尝试整个播放列表的长度
|
||||
|
||||
// 循环尝试找到可播放的上一首歌曲
|
||||
while (prevIndex >= 0 && attempts < maxAttempts) {
|
||||
const prevSong = currentPlaylist.value[prevIndex].song
|
||||
|
||||
// 如果已经有播放URL,直接使用
|
||||
if (prevSong.musicUrl) {
|
||||
currentPlaylistIndex.value = prevIndex
|
||||
currentSong.value = prevSong
|
||||
currentPosition.value = 0
|
||||
duration.value = 0 // 重置时长
|
||||
isPlaying.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果没有播放URL,尝试动态获取
|
||||
if (prevSong.musicPlatform && prevSong.musicId) {
|
||||
try {
|
||||
const { getMusicUrl } = await import('~/utils/musicUrl')
|
||||
|
||||
// 检查是否为播客内容
|
||||
const isPodcast =
|
||||
prevSong.musicPlatform === 'netease-podcast' ||
|
||||
prevSong.sourceInfo?.type === 'voice' ||
|
||||
(prevSong.sourceInfo?.source === 'netease-backup' &&
|
||||
prevSong.sourceInfo?.type === 'voice')
|
||||
const options: { unblock?: boolean; musicInfo?: MusicTrackMeta } = isPodcast
|
||||
? { unblock: false }
|
||||
: {}
|
||||
options.musicInfo = {
|
||||
name: prevSong.title,
|
||||
artist: prevSong.artist,
|
||||
album: prevSong.album || undefined
|
||||
}
|
||||
|
||||
const url = await getMusicUrl(
|
||||
prevSong.musicPlatform,
|
||||
prevSong.musicId,
|
||||
prevSong.playUrl,
|
||||
options
|
||||
)
|
||||
if (url) {
|
||||
prevSong.musicUrl = url
|
||||
currentPlaylistIndex.value = prevIndex
|
||||
currentSong.value = prevSong
|
||||
currentPosition.value = 0
|
||||
duration.value = 0 // 重置时长
|
||||
isPlaying.value = true
|
||||
return true
|
||||
} else {
|
||||
console.warn(`跳过第${prevIndex + 1}首歌曲:无法获取播放链接,可能是付费内容`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`跳过第${prevIndex + 1}首歌曲:获取播放URL失败`, error)
|
||||
}
|
||||
} else {
|
||||
console.warn(`跳过第${prevIndex + 1}首歌曲:缺少音乐平台或ID信息`)
|
||||
}
|
||||
|
||||
// 尝试上一首
|
||||
prevIndex--
|
||||
attempts++
|
||||
}
|
||||
|
||||
console.log('没有找到可播放的上一首歌曲')
|
||||
return false
|
||||
}
|
||||
|
||||
// 设置播放位置
|
||||
const setPosition = (position: number) => {
|
||||
currentPosition.value = Math.max(0, Math.min(position, duration.value))
|
||||
}
|
||||
|
||||
// 设置歌曲总时长
|
||||
const setDuration = (newDuration: number) => {
|
||||
duration.value = Math.max(0, newDuration)
|
||||
}
|
||||
|
||||
// 更新播放位置(由音频元素的timeupdate事件调用)
|
||||
const updatePosition = (position: number) => {
|
||||
currentPosition.value = position
|
||||
}
|
||||
|
||||
// 检查指定ID的歌曲是否正在播放
|
||||
const isCurrentPlaying = (songId: number | string) => {
|
||||
return isPlaying.value && currentSong.value && String(currentSong.value.id) === String(songId)
|
||||
}
|
||||
|
||||
// 检查指定ID的歌曲是否为当前歌曲(不管是否在播放)
|
||||
const isCurrentSong = (songId: number | string) => {
|
||||
return currentSong.value && String(currentSong.value.id) === String(songId)
|
||||
}
|
||||
|
||||
// 检查是否有下一首歌曲
|
||||
const hasNext = computed(() => {
|
||||
return (
|
||||
currentPlaylist.value.length > 0 &&
|
||||
currentPlaylistIndex.value >= 0 &&
|
||||
currentPlaylistIndex.value < currentPlaylist.value.length - 1
|
||||
)
|
||||
})
|
||||
|
||||
// 检查是否有上一首歌曲
|
||||
const hasPrevious = computed(() => {
|
||||
return currentPlaylist.value.length > 0 && currentPlaylistIndex.value > 0
|
||||
})
|
||||
|
||||
// 预载下一首歌曲的封面与歌词(切歌时直接命中缓存,减少等待)
|
||||
const preloadNextSongAssets = (song: PlayableSong) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// 封面预载
|
||||
if (song.cover) {
|
||||
const img = new Image()
|
||||
const clear = () => {
|
||||
if (pendingCoverImage === img) pendingCoverImage = null
|
||||
}
|
||||
img.onload = clear
|
||||
img.onerror = clear
|
||||
img.src = song.cover
|
||||
pendingCoverImage = img
|
||||
}
|
||||
|
||||
// 歌词预载:静默请求,参数与正式取词路径一致以命中 getLyrics 的请求缓存
|
||||
if (song.musicPlatform && song.musicId) {
|
||||
try {
|
||||
const { getLyrics } = useMusicSources()
|
||||
getLyrics(song.musicPlatform as 'netease' | 'tencent', song.musicId, {
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album || undefined,
|
||||
duration: song.duration || undefined,
|
||||
priority: useLyricSettings().lyricPriority.value
|
||||
}).catch(() => {})
|
||||
} catch (error) {
|
||||
console.warn('[AudioPlayer] 歌词预载失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提前预加载下一首歌曲的URL
|
||||
let isPrefetching = false
|
||||
const prefetchNextSong = async () => {
|
||||
if (isPrefetching || !hasNext.value) return false
|
||||
|
||||
const nextIndex = currentPlaylistIndex.value + 1
|
||||
const nextSong = currentPlaylist.value[nextIndex]?.song
|
||||
|
||||
if (!nextSong) return false
|
||||
|
||||
// 封面与歌词预载不依赖播放 URL,先行触发
|
||||
preloadNextSongAssets(nextSong)
|
||||
|
||||
if (nextSong.musicUrl || !nextSong.musicPlatform || !nextSong.musicId) {
|
||||
return false
|
||||
}
|
||||
|
||||
isPrefetching = true
|
||||
try {
|
||||
const { getMusicUrl } = await import('~/utils/musicUrl')
|
||||
|
||||
const isPodcast =
|
||||
nextSong.musicPlatform === 'netease-podcast' ||
|
||||
nextSong.sourceInfo?.type === 'voice' ||
|
||||
(nextSong.sourceInfo?.source === 'netease-backup' &&
|
||||
nextSong.sourceInfo?.type === 'voice')
|
||||
const options: { unblock?: boolean; musicInfo?: MusicTrackMeta } = isPodcast
|
||||
? { unblock: false }
|
||||
: {}
|
||||
options.musicInfo = {
|
||||
name: nextSong.title,
|
||||
artist: nextSong.artist,
|
||||
album: nextSong.album || undefined
|
||||
}
|
||||
|
||||
const url = await getMusicUrl(
|
||||
nextSong.musicPlatform,
|
||||
nextSong.musicId,
|
||||
nextSong.playUrl,
|
||||
options
|
||||
)
|
||||
|
||||
if (url) {
|
||||
nextSong.musicUrl = url
|
||||
console.log(`成功预加载下一首歌曲: ${nextSong.title}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`预加载下一首歌曲《${nextSong.title}》失败:`, error)
|
||||
// 注意:这里不显示错误通知,失败会在真正尝试播放时处理并提示
|
||||
} finally {
|
||||
isPrefetching = false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 获取当前播放列表
|
||||
const getCurrentPlaylist = () => {
|
||||
return readonly(currentPlaylist)
|
||||
}
|
||||
|
||||
// 获取当前播放列表索引
|
||||
const getCurrentPlaylistIndex = () => {
|
||||
return readonly(currentPlaylistIndex)
|
||||
}
|
||||
|
||||
// 获取当前播放的歌曲信息
|
||||
const getCurrentSong = () => {
|
||||
return readonly(currentSong)
|
||||
}
|
||||
|
||||
// 获取当前播放状态
|
||||
const getPlayingStatus = () => {
|
||||
return readonly(isPlaying)
|
||||
}
|
||||
|
||||
// 获取当前播放位置
|
||||
const getCurrentPosition = () => {
|
||||
return readonly(currentPosition)
|
||||
}
|
||||
|
||||
// 获取歌曲总时长
|
||||
const getDuration = () => {
|
||||
return readonly(duration)
|
||||
}
|
||||
|
||||
// 获取播放进度百分比
|
||||
const getProgress = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentPosition.value / duration.value) * 100
|
||||
})
|
||||
|
||||
// 更新当前歌曲信息(用于音质切换等场景)
|
||||
const updateCurrentSong = (updatedSong: PlayableSong) => {
|
||||
if (currentSong.value && String(currentSong.value.id) === String(updatedSong.id)) {
|
||||
currentSong.value = updatedSong
|
||||
// 同时更新播放列表中的对应项
|
||||
if (currentPlaylistIndex.value !== -1 && currentPlaylist.value[currentPlaylistIndex.value]) {
|
||||
currentPlaylist.value[currentPlaylistIndex.value].song = updatedSong
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
playSong,
|
||||
pauseSong,
|
||||
stopSong,
|
||||
playNext,
|
||||
playPrevious,
|
||||
updateCurrentSong,
|
||||
setPosition,
|
||||
setDuration,
|
||||
updatePosition,
|
||||
isCurrentPlaying,
|
||||
isCurrentSong,
|
||||
getCurrentSong,
|
||||
getPlayingStatus,
|
||||
getCurrentPosition,
|
||||
getDuration,
|
||||
getProgress,
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
prefetchNextSong,
|
||||
getCurrentPlaylist,
|
||||
getCurrentPlaylistIndex
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useAudioQuality } from '~/composables/useAudioQuality'
|
||||
import { useLyrics } from '~/composables/useLyrics'
|
||||
import { useAudioPlayer } from '~/composables/useAudioPlayer'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import type { MusicTrackMeta } from '~/utils/musicUrl'
|
||||
|
||||
// 单例状态
|
||||
const audioPlayer = ref<HTMLAudioElement | null>(null)
|
||||
const isPlaying = ref(false)
|
||||
const progress = ref(0)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const volume = ref(1) // 0.0 到 1.0
|
||||
const isMuted = ref(false)
|
||||
const preMuteVolume = ref(1)
|
||||
const hasError = ref(false)
|
||||
const coverError = ref(false)
|
||||
const showQualitySettings = ref(false)
|
||||
|
||||
// 拖拽状态
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartProgress = ref(0)
|
||||
const dragProgress = ref(0)
|
||||
|
||||
// 同步状态
|
||||
const isSyncingFromGlobal = ref(false)
|
||||
const isLoadingNewSong = ref(false)
|
||||
const isLoadingTrack = ref(false)
|
||||
const progressBarRef = ref<HTMLElement | null>(null)
|
||||
const hasUserInteracted = ref(false)
|
||||
|
||||
// 播放模式: 'off' (单曲播放完退出) | 'order' (顺序播放列表) | 'loopOne' (单曲循环)
|
||||
const playMode = ref<'off' | 'order' | 'loopOne'>('order')
|
||||
|
||||
// 共享歌词实例
|
||||
const lyrics = useLyrics()
|
||||
|
||||
export const useAudioPlayerControl = () => {
|
||||
const { audioPlayer: audioPlayerLocale } = useLocale()
|
||||
// 音质 (Composable 使用应在 setup/function 内部)
|
||||
const { getQualityLabel, getQuality, getQualityOptions, saveQuality } = useAudioQuality()
|
||||
|
||||
// 全局音频播放器
|
||||
const globalAudioPlayer = useAudioPlayer()
|
||||
|
||||
// 基本播放控制
|
||||
const play = async (): Promise<boolean> => {
|
||||
// 等待音频播放器引用设置完成
|
||||
let retryCount = 0
|
||||
const maxRetries = 20
|
||||
|
||||
while (!audioPlayer.value && retryCount < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
retryCount++
|
||||
}
|
||||
|
||||
if (!audioPlayer.value || hasError.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保音频已经加载
|
||||
if (audioPlayer.value.readyState < 2) {
|
||||
await waitForCanPlay(audioPlayer.value)
|
||||
}
|
||||
|
||||
// 设置音频属性以支持自动播放
|
||||
audioPlayer.value.autoplay = true
|
||||
audioPlayer.value.preload = 'auto'
|
||||
|
||||
const playPromise = audioPlayer.value.play()
|
||||
|
||||
// 处理播放 Promise
|
||||
if (playPromise !== undefined) {
|
||||
await playPromise
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
// 检查是否是自动播放被阻止的错误
|
||||
if (error.name === 'NotAllowedError') {
|
||||
console.warn('[AudioPlayerControl] ⚠️ 自动播放被浏览器阻止,需要用户交互')
|
||||
if (typeof window !== 'undefined' && window.$showNotification) {
|
||||
window.$showNotification(audioPlayerLocale.value.autoplayBlocked, 'info')
|
||||
}
|
||||
// 不设置 hasError,因为这不是真正的错误
|
||||
return false
|
||||
} else {
|
||||
console.error('[AudioPlayerControl] ❌ 播放失败:', error)
|
||||
hasError.value = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pause = (): boolean => {
|
||||
if (!audioPlayer.value) {
|
||||
console.warn('[AudioPlayerControl] pause called but audioPlayer is null')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
audioPlayer.value.pause()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('暂停失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const stop = (): boolean => {
|
||||
if (!audioPlayer.value) return false
|
||||
|
||||
try {
|
||||
audioPlayer.value.pause()
|
||||
audioPlayer.value.src = ''
|
||||
audioPlayer.value.load()
|
||||
|
||||
// 清理歌词状态
|
||||
lyrics.clearLyrics()
|
||||
resetState()
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('停止失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const seek = async (timeInSeconds: number): Promise<boolean> => {
|
||||
if (!audioPlayer.value) return false
|
||||
|
||||
try {
|
||||
// 如果 duration 为 0,可能元数据还未加载,直接使用传入时间
|
||||
const maxTime = duration.value > 0 ? duration.value : timeInSeconds + 1000
|
||||
const targetTime = Math.max(0, Math.min(timeInSeconds, maxTime))
|
||||
|
||||
audioPlayer.value.currentTime = targetTime
|
||||
currentTime.value = targetTime
|
||||
if (duration.value > 0) {
|
||||
progress.value = (targetTime / duration.value) * 100
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('跳转失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 抽象后的 seekAndPlay 方法
|
||||
const seekAndPlay = async (timeInSeconds: number) => {
|
||||
if (!audioPlayer.value) return
|
||||
|
||||
// 1. 跳转
|
||||
await seek(timeInSeconds)
|
||||
|
||||
// 2. 如果暂停则播放
|
||||
try {
|
||||
await audioPlayer.value.play()
|
||||
} catch (e) {
|
||||
console.warn('Play failed:', e)
|
||||
}
|
||||
|
||||
// 3. 与全局音频播放器同步状态
|
||||
globalAudioPlayer.setPosition(timeInSeconds)
|
||||
if (!globalAudioPlayer.getPlayingStatus().value) {
|
||||
const current = globalAudioPlayer.getCurrentSong().value
|
||||
if (current) {
|
||||
globalAudioPlayer.playSong(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 等待音频可以播放
|
||||
const waitForCanPlay = (audio: HTMLAudioElement): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
audio.removeEventListener('error', onError)
|
||||
reject(new Error('音频加载超时'))
|
||||
}, 10000) // 10秒超时
|
||||
|
||||
const onCanPlay = () => {
|
||||
clearTimeout(timeout)
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
audio.removeEventListener('error', onError)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onError = (error: Event) => {
|
||||
clearTimeout(timeout)
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
audio.removeEventListener('error', onError)
|
||||
|
||||
if (!audio.src || audio.src === window.location.href || audio.src === window.location.origin + '/') {
|
||||
reject(new Error('AbortError'))
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
audio.addEventListener('canplay', onCanPlay)
|
||||
audio.addEventListener('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
// 加载新歌曲
|
||||
const loadSong = async (
|
||||
songUrlOrSong: string | any,
|
||||
retryCount: number = 0
|
||||
): Promise<boolean> => {
|
||||
if (!audioPlayer.value) return false
|
||||
|
||||
stop()
|
||||
isLoadingNewSong.value = true
|
||||
isLoadingTrack.value = true // 开始加载时设置加载状态
|
||||
hasError.value = false
|
||||
|
||||
// 立即清空之前的歌词,避免显示上一首歌的歌词
|
||||
lyrics.clearLyrics()
|
||||
|
||||
try {
|
||||
let songUrl: string
|
||||
let songInfo: any = null
|
||||
const lyricsPromise: Promise<void> | null = null
|
||||
|
||||
// 如果传入的是歌曲对象,检查是否有音乐平台信息
|
||||
if (typeof songUrlOrSong === 'object' && songUrlOrSong !== null) {
|
||||
songInfo = songUrlOrSong
|
||||
|
||||
// 优先检查是否已有直接的播放URL
|
||||
if (songUrlOrSong.musicUrl) {
|
||||
songUrl = songUrlOrSong.musicUrl
|
||||
|
||||
// 清理URL中的反引号和空格(特别是网易云备用源)
|
||||
songUrl = songUrl.trim().replace(/`/g, '')
|
||||
console.log('使用已有的播放URL:', songUrl)
|
||||
|
||||
// 如果有音乐平台信息,加载歌词
|
||||
if (songUrlOrSong.musicPlatform && songUrlOrSong.musicId) {
|
||||
// lyricsPromise = lyrics.fetchLyrics(songUrlOrSong.musicPlatform, songUrlOrSong.musicId)
|
||||
// 迁移到 useLyricManager 统一管理,避免重复请求
|
||||
}
|
||||
} else if (songUrlOrSong.musicPlatform && songUrlOrSong.musicId) {
|
||||
// 检查是否是网易云备用源,如果是则不应该调用getMusicUrl
|
||||
if (songUrlOrSong.sourceInfo?.source === 'netease-backup') {
|
||||
throw new Error('网易云备用源歌曲缺少播放URL,请重新获取')
|
||||
}
|
||||
|
||||
console.log('正在获取歌曲URL:', songUrlOrSong.musicPlatform, songUrlOrSong.musicId)
|
||||
|
||||
// 检查是否为播客内容
|
||||
const isPodcast =
|
||||
songUrlOrSong.musicPlatform === 'netease-podcast' ||
|
||||
songUrlOrSong.sourceInfo?.type === 'voice' ||
|
||||
(songUrlOrSong.sourceInfo?.source === 'netease-backup' &&
|
||||
songUrlOrSong.sourceInfo?.type === 'voice')
|
||||
const options: { unblock?: boolean; musicInfo?: MusicTrackMeta } = isPodcast
|
||||
? { unblock: false }
|
||||
: {}
|
||||
options.musicInfo = {
|
||||
name: songUrlOrSong.title,
|
||||
artist: songUrlOrSong.artist,
|
||||
album: songUrlOrSong.album || undefined
|
||||
}
|
||||
|
||||
songUrl = await getMusicUrl(
|
||||
songUrlOrSong.musicPlatform,
|
||||
songUrlOrSong.musicId,
|
||||
songUrlOrSong.playUrl,
|
||||
options
|
||||
)
|
||||
if (!songUrl) {
|
||||
throw new Error('无法获取歌曲URL')
|
||||
}
|
||||
|
||||
// 并行加载歌词(不阻塞音频加载)
|
||||
// lyricsPromise = lyrics.fetchLyrics(songUrlOrSong.musicPlatform, songUrlOrSong.musicId)
|
||||
// 迁移到 useLyricManager 统一管理,避免重复请求
|
||||
} else {
|
||||
throw new Error('歌曲缺少播放信息(音乐平台ID或直接URL)')
|
||||
}
|
||||
} else if (typeof songUrlOrSong === 'string') {
|
||||
songUrl = songUrlOrSong
|
||||
} else {
|
||||
throw new Error('无效的歌曲参数')
|
||||
}
|
||||
|
||||
console.log('设置音频源:', songUrl)
|
||||
audioPlayer.value.src = songUrl
|
||||
audioPlayer.value.load()
|
||||
|
||||
// 等待音频可以播放
|
||||
await waitForCanPlay(audioPlayer.value)
|
||||
|
||||
// 等待歌词加载完成(如果有的话)
|
||||
if (lyricsPromise) {
|
||||
try {
|
||||
await lyricsPromise
|
||||
// 歌词加载完成
|
||||
} catch (lyricsError) {
|
||||
console.warn('歌词加载失败,但继续播放音频:', lyricsError)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('歌曲加载成功:', songInfo?.title || songUrl)
|
||||
isLoadingNewSong.value = false
|
||||
|
||||
// 自动开始播放
|
||||
console.log('尝试自动播放音乐')
|
||||
const playResult = await play()
|
||||
|
||||
if (!playResult) {
|
||||
console.log('自动播放失败,可能被浏览器阻止,等待用户交互')
|
||||
} else {
|
||||
console.log('自动播放成功')
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'AbortError') {
|
||||
console.log('音频加载被主动中止')
|
||||
return false
|
||||
}
|
||||
|
||||
// 重试逻辑(针对网络抖动等临时错误)
|
||||
if (
|
||||
retryCount < 2 &&
|
||||
!hasError.value &&
|
||||
typeof songUrlOrSong === 'object' &&
|
||||
songUrlOrSong?.musicPlatform
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
if (hasError.value) return false
|
||||
return await loadSong(songUrlOrSong, retryCount + 1)
|
||||
}
|
||||
|
||||
hasError.value = true
|
||||
isLoadingNewSong.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换播放/暂停
|
||||
const togglePlay = async (): Promise<boolean> => {
|
||||
// 标记用户已交互
|
||||
hasUserInteracted.value = true
|
||||
|
||||
if (isPlaying.value) {
|
||||
return pause()
|
||||
} else {
|
||||
return await play()
|
||||
}
|
||||
}
|
||||
|
||||
// 音质切换
|
||||
const switchQuality = async (
|
||||
platform: string,
|
||||
musicId: string,
|
||||
qualityValue: string
|
||||
): Promise<boolean> => {
|
||||
if (!audioPlayer.value) return false
|
||||
|
||||
// 保存当前播放进度和状态
|
||||
const currentTimeBackup = audioPlayer.value.currentTime
|
||||
const wasPlaying = isPlaying.value
|
||||
|
||||
// 立即暂停
|
||||
if (wasPlaying) {
|
||||
pause()
|
||||
}
|
||||
|
||||
try {
|
||||
// 保存音质设置
|
||||
saveQuality(platform, qualityValue)
|
||||
|
||||
// 获取新音质的URL
|
||||
const currentSongForQuality = globalAudioPlayer.getCurrentSong().value
|
||||
const newUrl = await getMusicUrl(
|
||||
platform,
|
||||
musicId,
|
||||
currentSongForQuality?.playUrl,
|
||||
{
|
||||
musicInfo: {
|
||||
name: currentSongForQuality?.title,
|
||||
artist: currentSongForQuality?.artist,
|
||||
album: currentSongForQuality?.album || undefined
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!newUrl) {
|
||||
throw new Error('获取新音质URL失败')
|
||||
}
|
||||
|
||||
// 加载新音频
|
||||
const loadSuccess = await loadSong(newUrl)
|
||||
if (!loadSuccess) {
|
||||
throw new Error('加载新音质失败')
|
||||
}
|
||||
|
||||
// 恢复播放进度
|
||||
if (currentTimeBackup > 0) {
|
||||
await seek(currentTimeBackup)
|
||||
}
|
||||
|
||||
// 如果之前在播放,恢复播放状态
|
||||
if (wasPlaying) {
|
||||
// 先尝试播放,再设置进度,避免自动播放限制
|
||||
try {
|
||||
await play()
|
||||
if (currentTimeBackup > 0) {
|
||||
await nextTick()
|
||||
await seek(currentTimeBackup)
|
||||
}
|
||||
} catch (playError) {
|
||||
console.warn('恢复播放失败,可能需要用户手动播放:', playError)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('切换音质失败:', error)
|
||||
|
||||
// 如果切换失败,尝试恢复之前的播放状态
|
||||
if (wasPlaying && !audioPlayer.value.error) {
|
||||
try {
|
||||
await play()
|
||||
} catch (playError) {
|
||||
console.error('恢复播放失败:', playError)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 动态获取音乐URL(委托到统一逻辑)
|
||||
const getMusicUrl = async (
|
||||
platform: string,
|
||||
musicId: string,
|
||||
playUrl: string | null = null,
|
||||
options?: {
|
||||
unblock?: boolean
|
||||
quality?: number
|
||||
musicInfo?: MusicTrackMeta
|
||||
}
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const { getMusicUrl: coreGetMusicUrl } = await import('~/utils/musicUrl')
|
||||
const url = await coreGetMusicUrl(platform, musicId, playUrl ?? undefined, options)
|
||||
return url
|
||||
} catch (error) {
|
||||
console.error('获取音乐URL错误:', error)
|
||||
if (error instanceof TypeError && (error as any).message?.includes('fetch')) {
|
||||
throw new Error('网络连接失败,请检查网络连接')
|
||||
} else if ((error as any).name === 'AbortError') {
|
||||
throw new Error('请求超时,请稍后重试')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 进度条拖拽
|
||||
const startDrag = (event: MouseEvent, progressBar: HTMLElement) => {
|
||||
if (event.button !== 0) return
|
||||
|
||||
if (!audioPlayer.value || !progressBar) {
|
||||
return
|
||||
}
|
||||
|
||||
isDragging.value = true
|
||||
dragStartX.value = event.clientX
|
||||
dragStartProgress.value = progress.value
|
||||
|
||||
const onDrag = (e: MouseEvent) => {
|
||||
if (!isDragging.value || !progressBar) return
|
||||
|
||||
const rect = progressBar.getBoundingClientRect()
|
||||
const newX = e.clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(100, (newX / rect.width) * 100))
|
||||
|
||||
progress.value = percentage
|
||||
|
||||
// 拖拽时只更新进度条显示,不实时更新音频位置
|
||||
if (duration.value) {
|
||||
const newTime = (percentage / 100) * duration.value
|
||||
currentTime.value = newTime
|
||||
}
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', endDrag)
|
||||
|
||||
// 拖拽结束时才更新音频位置
|
||||
if (audioPlayer.value && duration.value) {
|
||||
const newTime = (progress.value / 100) * duration.value
|
||||
audioPlayer.value.currentTime = newTime
|
||||
currentTime.value = newTime
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', endDrag)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// 触摸拖拽
|
||||
const startTouchDrag = (event: TouchEvent, progressBar: HTMLElement) => {
|
||||
if (event.touches.length !== 1) return
|
||||
|
||||
if (!audioPlayer.value || !progressBar) {
|
||||
return
|
||||
}
|
||||
|
||||
// 阻止默认行为,防止页面滚动
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
isDragging.value = true
|
||||
const touch = event.touches[0]
|
||||
dragStartX.value = touch.clientX
|
||||
dragStartProgress.value = progress.value
|
||||
|
||||
const onTouchDrag = (e: TouchEvent) => {
|
||||
if (!isDragging.value || !progressBar || e.touches.length !== 1) return
|
||||
|
||||
// 阻止默认行为和事件冒泡
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const touch = e.touches[0]
|
||||
const rect = progressBar.getBoundingClientRect()
|
||||
const newX = touch.clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(100, (newX / rect.width) * 100))
|
||||
|
||||
progress.value = percentage
|
||||
|
||||
// 拖拽时只更新进度条显示,不实时更新音频位置
|
||||
if (duration.value) {
|
||||
const newTime = (percentage / 100) * duration.value
|
||||
currentTime.value = newTime
|
||||
}
|
||||
}
|
||||
|
||||
const endTouchDrag = (e: TouchEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
isDragging.value = false
|
||||
document.removeEventListener('touchmove', onTouchDrag)
|
||||
document.removeEventListener('touchend', endTouchDrag)
|
||||
|
||||
// 触摸结束时才更新音频位置
|
||||
if (audioPlayer.value && duration.value) {
|
||||
const newTime = (progress.value / 100) * duration.value
|
||||
audioPlayer.value.currentTime = newTime
|
||||
currentTime.value = newTime
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('touchmove', onTouchDrag, { passive: false })
|
||||
document.addEventListener('touchend', endTouchDrag, { passive: false })
|
||||
}
|
||||
|
||||
// 进度条点击跳转
|
||||
const seekToPosition = (event: MouseEvent) => {
|
||||
if (!audioPlayer.value || isDragging.value) return
|
||||
|
||||
const progressBar = event.currentTarget as HTMLElement
|
||||
if (!progressBar) {
|
||||
return
|
||||
}
|
||||
|
||||
const clickPosition = event.offsetX
|
||||
const barWidth = progressBar.clientWidth
|
||||
const seekPercentage = clickPosition / barWidth
|
||||
|
||||
const newTime = seekPercentage * duration.value
|
||||
seek(newTime)
|
||||
}
|
||||
|
||||
// 音质相关计算属性
|
||||
const currentQualityText = computed(() => {
|
||||
return (platform: string) => {
|
||||
if (!platform) return '音质'
|
||||
const quality = getQuality(platform)
|
||||
const label = getQualityLabel(platform, quality)
|
||||
return label.replace(/音质|音乐/, '').trim() || '音质'
|
||||
}
|
||||
})
|
||||
|
||||
const currentPlatformOptions = computed(() => {
|
||||
return (platform: string) => {
|
||||
if (!platform) return []
|
||||
return getQualityOptions(platform)
|
||||
}
|
||||
})
|
||||
|
||||
const isCurrentQuality = (platform: string, qualityValue: string) => {
|
||||
if (!platform) return false
|
||||
return getQuality(platform) === qualityValue
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (seconds: number) => {
|
||||
if (!seconds || isNaN(seconds)) return '0:00'
|
||||
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 音频事件处理
|
||||
let hasPrefetchedForCurrentSong = false
|
||||
|
||||
const onTimeUpdate = (currentTimeValue: number) => {
|
||||
if (isDragging.value) return
|
||||
|
||||
currentTime.value = currentTimeValue
|
||||
if (duration.value > 0) {
|
||||
progress.value = (currentTimeValue / duration.value) * 100
|
||||
|
||||
// 距离结束还有15秒时,提前预加载下一首
|
||||
if (!hasPrefetchedForCurrentSong && duration.value - currentTimeValue <= 15) {
|
||||
hasPrefetchedForCurrentSong = true
|
||||
globalAudioPlayer.prefetchNextSong()
|
||||
}
|
||||
}
|
||||
|
||||
// 更新歌词时间
|
||||
const timeInMs = currentTimeValue * 1000
|
||||
lyrics.updateCurrentLyricIndex(timeInMs) // 转换为毫秒
|
||||
}
|
||||
|
||||
const onLoaded = (durationValue: number) => {
|
||||
duration.value = durationValue
|
||||
hasError.value = false
|
||||
hasPrefetchedForCurrentSong = false // 重置预加载标志
|
||||
}
|
||||
|
||||
const onError = (error: any) => {
|
||||
// 忽略主动清空 src 或关闭播放器导致的错误
|
||||
const audioEl = audioPlayer.value
|
||||
if (!audioEl || !audioEl.src || audioEl.src === window.location.href || audioEl.src === window.location.origin + '/') {
|
||||
return
|
||||
}
|
||||
|
||||
console.error('音频播放错误:', error)
|
||||
|
||||
// 获取更详细的错误信息
|
||||
let errorMessage = '音频播放失败'
|
||||
if (audioPlayer.value && audioPlayer.value.error) {
|
||||
const mediaError = audioPlayer.value.error
|
||||
switch (mediaError.code) {
|
||||
case MediaError.MEDIA_ERR_ABORTED:
|
||||
errorMessage = '音频加载被中止'
|
||||
break
|
||||
case MediaError.MEDIA_ERR_NETWORK:
|
||||
errorMessage = '网络错误,无法加载音频'
|
||||
break
|
||||
case MediaError.MEDIA_ERR_DECODE:
|
||||
errorMessage = '音频解码失败,格式不支持'
|
||||
break
|
||||
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||
errorMessage = '音频源不支持或无效'
|
||||
break
|
||||
default:
|
||||
errorMessage = `音频播放错误 (代码: ${mediaError.code})`
|
||||
}
|
||||
console.error(`详细错误信息: ${errorMessage}`, {
|
||||
code: mediaError.code,
|
||||
message: mediaError.message,
|
||||
src: audioPlayer.value.src
|
||||
})
|
||||
}
|
||||
|
||||
hasError.value = true
|
||||
isPlaying.value = false
|
||||
|
||||
// 显示用户友好的错误提示
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(errorMessage, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const onPlay = () => {
|
||||
isPlaying.value = true
|
||||
hasError.value = false
|
||||
isLoadingTrack.value = false // 音频开始播放时立即清除加载状态
|
||||
}
|
||||
|
||||
const onPause = () => {
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
const onEnded = () => {
|
||||
// 根据播放模式处理播放结束事件
|
||||
if (playMode.value === 'loopOne') {
|
||||
// 单曲循环:重新播放当前歌曲
|
||||
if (audioPlayer.value) {
|
||||
audioPlayer.value.currentTime = 0
|
||||
// 使用 Promise 处理播放,避免自动播放策略问题
|
||||
const playPromise = audioPlayer.value.play()
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch((error) => {
|
||||
console.error('单曲循环播放失败:', error)
|
||||
// 如果自动播放失败,更新播放状态
|
||||
isPlaying.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (playMode.value === 'order') {
|
||||
// 顺序播放:播放下一首
|
||||
// 如果没有下一首(列表结束),useAudioPlayer.playNext 会返回 false,此时自然停止
|
||||
const hasNext = globalAudioPlayer.hasNext.value
|
||||
if (hasNext) {
|
||||
globalAudioPlayer.playNext()
|
||||
} else {
|
||||
// 列表播放结束,停止播放
|
||||
isPlaying.value = false
|
||||
progress.value = 0
|
||||
currentTime.value = 0
|
||||
}
|
||||
} else {
|
||||
// 默认/播放完退出 (off):停止播放
|
||||
isPlaying.value = false
|
||||
progress.value = 0
|
||||
currentTime.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const onLoadStart = () => {
|
||||
hasError.value = false
|
||||
}
|
||||
|
||||
const onCanPlay = () => {
|
||||
hasError.value = false
|
||||
}
|
||||
|
||||
// 设置进度条引用
|
||||
const setProgressBarRef = (element: HTMLElement | null) => {
|
||||
progressBarRef.value = element
|
||||
}
|
||||
|
||||
// 设置音频播放器引用
|
||||
const setAudioPlayerRef = (element: HTMLAudioElement | null) => {
|
||||
audioPlayer.value = element
|
||||
if (element) {
|
||||
element.volume = volume.value
|
||||
}
|
||||
}
|
||||
|
||||
// 清理资源
|
||||
const cleanup = () => {
|
||||
if (audioPlayer.value) {
|
||||
// 先暂停播放
|
||||
audioPlayer.value.pause()
|
||||
|
||||
// 不设置空的 src,避免触发 MEDIA_ERR_SRC_NOT_SUPPORTED 错误
|
||||
// 只是暂停播放即可,让组件自然销毁
|
||||
}
|
||||
resetState()
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
const resetState = () => {
|
||||
isPlaying.value = false
|
||||
progress.value = 0
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
hasError.value = false
|
||||
coverError.value = false
|
||||
isDragging.value = false
|
||||
isSyncingFromGlobal.value = false
|
||||
isLoadingNewSong.value = false
|
||||
isLoadingTrack.value = false
|
||||
}
|
||||
|
||||
// 强制更新位置(用于鸿蒙侧同步)
|
||||
const forceUpdatePosition = (timeInSeconds: number) => {
|
||||
// 如果正在拖拽,不要更新位置
|
||||
if (isDragging.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!audioPlayer.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置同步标志,防止触发其他事件
|
||||
isSyncingFromGlobal.value = true
|
||||
|
||||
// 如果有duration,使用它来限制范围;否则直接使用传入的时间
|
||||
const targetTime =
|
||||
duration.value > 0
|
||||
? Math.max(0, Math.min(timeInSeconds, duration.value))
|
||||
: Math.max(0, timeInSeconds)
|
||||
|
||||
// 更新音频播放器位置
|
||||
audioPlayer.value.currentTime = targetTime
|
||||
|
||||
// 强制更新UI状态
|
||||
currentTime.value = targetTime
|
||||
|
||||
// 只有在duration存在时才计算进度百分比
|
||||
if (duration.value > 0) {
|
||||
progress.value = (targetTime / duration.value) * 100
|
||||
}
|
||||
|
||||
// 更新歌词时间
|
||||
const timeInMs = targetTime * 1000
|
||||
lyrics.updateCurrentLyricIndex(timeInMs)
|
||||
|
||||
// 使用nextTick确保DOM更新
|
||||
nextTick(() => {
|
||||
isSyncingFromGlobal.value = false
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
isSyncingFromGlobal.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 设置播放模式
|
||||
const setPlayMode = (mode: 'off' | 'order' | 'loopOne') => {
|
||||
if (['off', 'order', 'loopOne'].includes(mode)) {
|
||||
playMode.value = mode
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 音量控制
|
||||
const setVolume = (val: number) => {
|
||||
const newVolume = Math.max(0, Math.min(1, val))
|
||||
|
||||
// 当用户手动调节非0音量时,记录为下一次取消静音的恢复值
|
||||
if (newVolume > 0) {
|
||||
preMuteVolume.value = newVolume
|
||||
}
|
||||
|
||||
volume.value = newVolume
|
||||
isMuted.value = newVolume === 0
|
||||
if (audioPlayer.value) {
|
||||
audioPlayer.value.volume = newVolume
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
if (isMuted.value || volume.value === 0) {
|
||||
// 取消静音:如果用户手动把音量拉到 0 后又点击了恢复,此时记录的 preMuteVolume 会被用来恢复
|
||||
// 但如果连 preMuteVolume 记录的值都极小(如初始状态就是0),那就默认恢复到 10%
|
||||
setVolume(preMuteVolume.value > 0.01 ? preMuteVolume.value : 0.1)
|
||||
} else {
|
||||
// 触发静音前,再更新一次历史音量
|
||||
preMuteVolume.value = volume.value
|
||||
setVolume(0)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
audioPlayer,
|
||||
isPlaying,
|
||||
progress,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
hasError,
|
||||
coverError,
|
||||
showQualitySettings,
|
||||
isDragging,
|
||||
isSyncingFromGlobal,
|
||||
isLoadingNewSong,
|
||||
isLoadingTrack,
|
||||
progressBarRef,
|
||||
hasUserInteracted,
|
||||
playMode, // 暴露播放模式
|
||||
|
||||
// 基本控制
|
||||
play,
|
||||
setPlayMode, // 暴露设置播放模式方法
|
||||
pause,
|
||||
stop,
|
||||
seek,
|
||||
seekAndPlay, // 新增
|
||||
togglePlay,
|
||||
loadSong,
|
||||
forceUpdatePosition,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
|
||||
// 音频事件处理
|
||||
onTimeUpdate,
|
||||
onLoaded,
|
||||
onError,
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
onLoadStart,
|
||||
onCanPlay,
|
||||
setProgressBarRef,
|
||||
setAudioPlayerRef,
|
||||
|
||||
// 音质控制
|
||||
switchQuality,
|
||||
currentQualityText,
|
||||
currentPlatformOptions,
|
||||
isCurrentQuality,
|
||||
|
||||
// 拖拽控制
|
||||
startDrag,
|
||||
startTouchDrag,
|
||||
seekToPosition,
|
||||
|
||||
// 工具函数
|
||||
formatTime,
|
||||
resetState,
|
||||
waitForCanPlay,
|
||||
cleanup,
|
||||
|
||||
// 歌词功能
|
||||
lyrics
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAudioQuality } from './useAudioQuality'
|
||||
import type { MusicTrackMeta } from '~/utils/musicUrl'
|
||||
|
||||
export const useAudioPlayerEnhanced = () => {
|
||||
const { getQuality, getQualityOptions, saveQuality } = useAudioQuality()
|
||||
|
||||
// 使用全局通知函数
|
||||
const addNotification = (options) => {
|
||||
if (typeof window !== 'undefined' && window.$showNotification) {
|
||||
if (typeof options === 'string') {
|
||||
window.$showNotification(options, 'info')
|
||||
} else {
|
||||
window.$showNotification(
|
||||
options.message,
|
||||
options.type || 'info',
|
||||
true,
|
||||
options.duration || 3000
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.log('通知:', options.message || options)
|
||||
}
|
||||
}
|
||||
|
||||
const retryCount = ref(0)
|
||||
const maxRetries = ref(3)
|
||||
const isRetrying = ref(false)
|
||||
const qualitySwitchAttempted = ref(false)
|
||||
|
||||
// 重置重试状态
|
||||
const resetRetryState = () => {
|
||||
retryCount.value = 0
|
||||
isRetrying.value = false
|
||||
qualitySwitchAttempted.value = false
|
||||
}
|
||||
|
||||
// 获取音乐链接(统一委托 + 支持传入临时音质或直接播放链接)
|
||||
const getMusicUrl = async (
|
||||
platform,
|
||||
musicId,
|
||||
qualityOrPlayUrl,
|
||||
options?: { unblock?: boolean; quality?: number; musicInfo?: MusicTrackMeta }
|
||||
) => {
|
||||
try {
|
||||
const requestOptions =
|
||||
typeof qualityOrPlayUrl === 'number'
|
||||
? { ...options, quality: qualityOrPlayUrl }
|
||||
: options
|
||||
|
||||
const { getMusicUrl: coreGetMusicUrl } = await import('~/utils/musicUrl')
|
||||
const url = await coreGetMusicUrl(
|
||||
platform,
|
||||
musicId,
|
||||
typeof qualityOrPlayUrl === 'string' ? qualityOrPlayUrl : undefined,
|
||||
requestOptions
|
||||
)
|
||||
|
||||
if (url) {
|
||||
return { success: true, url }
|
||||
}
|
||||
return { success: false, error: '未获取到播放链接' }
|
||||
} catch (error) {
|
||||
console.error('获取音乐链接失败:', error)
|
||||
return { success: false, error: (error as any)?.message || '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试切换音质
|
||||
const tryQualitySwitch = async (song) => {
|
||||
if (!song?.musicPlatform || !song?.musicId || qualitySwitchAttempted.value) {
|
||||
return { success: false, error: '无法切换音质' }
|
||||
}
|
||||
|
||||
const platform = song.musicPlatform
|
||||
const currentQuality = getQuality(platform)
|
||||
const qualityOptions = getQualityOptions(platform)
|
||||
|
||||
// 找到当前音质的索引
|
||||
const currentIndex = qualityOptions.findIndex((option) => option.value === currentQuality)
|
||||
|
||||
// 尝试下一个音质
|
||||
for (let i = currentIndex + 1; i < qualityOptions.length; i++) {
|
||||
const newQuality = qualityOptions[i].value
|
||||
console.log(`尝试切换到音质: ${qualityOptions[i].label}`)
|
||||
|
||||
// 检查是否为播客内容
|
||||
const isPodcast =
|
||||
song.musicPlatform === 'netease-podcast' ||
|
||||
song.sourceInfo?.type === 'voice' ||
|
||||
(song.sourceInfo?.source === 'netease-backup' && song.sourceInfo?.type === 'voice')
|
||||
const options: { unblock?: boolean; musicInfo?: MusicTrackMeta } = isPodcast
|
||||
? { unblock: false }
|
||||
: {}
|
||||
options.musicInfo = {
|
||||
name: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album || undefined
|
||||
}
|
||||
|
||||
const result = await getMusicUrl(platform, song.musicId, newQuality, options)
|
||||
if (result.success) {
|
||||
// 保存新音质设置
|
||||
saveQuality(platform, newQuality)
|
||||
qualitySwitchAttempted.value = true
|
||||
|
||||
addNotification({
|
||||
type: 'info',
|
||||
message: `已切换到${qualityOptions[i].label}`,
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
url: result.url,
|
||||
quality: newQuality,
|
||||
qualityLabel: qualityOptions[i].label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有更低音质都失败,尝试更高音质
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const newQuality = qualityOptions[i].value
|
||||
console.log(`尝试切换到音质: ${qualityOptions[i].label}`)
|
||||
|
||||
// 检查是否为播客内容
|
||||
const isPodcast =
|
||||
song.musicPlatform === 'netease-podcast' ||
|
||||
song.sourceInfo?.type === 'voice' ||
|
||||
(song.sourceInfo?.source === 'netease-backup' && song.sourceInfo?.type === 'voice')
|
||||
const options: { unblock?: boolean; musicInfo?: MusicTrackMeta } = isPodcast
|
||||
? { unblock: false }
|
||||
: {}
|
||||
options.musicInfo = {
|
||||
name: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album || undefined
|
||||
}
|
||||
|
||||
const result = await getMusicUrl(platform, song.musicId, newQuality, options)
|
||||
if (result.success) {
|
||||
// 保存新音质设置
|
||||
saveQuality(platform, newQuality)
|
||||
qualitySwitchAttempted.value = true
|
||||
|
||||
addNotification({
|
||||
type: 'info',
|
||||
message: `已切换到${qualityOptions[i].label}`,
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
url: result.url,
|
||||
quality: newQuality,
|
||||
qualityLabel: qualityOptions[i].label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qualitySwitchAttempted.value = true
|
||||
return { success: false, error: '所有音质都无法播放' }
|
||||
}
|
||||
|
||||
// 处理播放错误,包含重试和音质切换逻辑
|
||||
const handlePlaybackError = async (
|
||||
error,
|
||||
song,
|
||||
onSongChange,
|
||||
onNext,
|
||||
onClose,
|
||||
isPlaylistMode = true
|
||||
) => {
|
||||
console.log('播放错误:', error, '重试次数:', retryCount.value)
|
||||
|
||||
// 如果已经在重试中,避免重复处理
|
||||
if (isRetrying.value) {
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
isRetrying.value = true
|
||||
|
||||
try {
|
||||
// 首先尝试音质切换(只尝试一次)
|
||||
if (!qualitySwitchAttempted.value && song?.musicPlatform && song?.musicId) {
|
||||
console.log('尝试切换音质解决播放问题...')
|
||||
const qualityResult = await tryQualitySwitch(song)
|
||||
|
||||
if (qualityResult.success) {
|
||||
// 更新歌曲的音乐链接
|
||||
const updatedSong = {
|
||||
...song,
|
||||
musicUrl: qualityResult.url
|
||||
}
|
||||
|
||||
// 通知父组件更新歌曲
|
||||
if (onSongChange) {
|
||||
onSongChange(updatedSong)
|
||||
}
|
||||
|
||||
isRetrying.value = false
|
||||
return { handled: true, success: true, newSong: updatedSong }
|
||||
}
|
||||
}
|
||||
|
||||
// 如果音质切换失败或不可用,尝试重试当前歌曲
|
||||
if (retryCount.value < maxRetries.value) {
|
||||
retryCount.value++
|
||||
console.log(`重试播放当前歌曲 (${retryCount.value}/${maxRetries.value})`)
|
||||
|
||||
// 延迟重试
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount.value))
|
||||
|
||||
isRetrying.value = false
|
||||
return { handled: true, shouldRetry: true }
|
||||
}
|
||||
|
||||
// 重试次数用完,根据播放模式决定是否自动跳过
|
||||
if (isPlaylistMode) {
|
||||
console.log('当前歌曲重试次数用完,尝试下一首')
|
||||
|
||||
if (onNext) {
|
||||
const nextResult = await onNext()
|
||||
if (nextResult?.success && nextResult?.newSong) {
|
||||
// 重置重试状态,为新歌曲准备
|
||||
resetRetryState()
|
||||
isRetrying.value = false
|
||||
return { handled: true, success: true, newSong: nextResult.newSong }
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有下一首或下一首也失败,关闭播放器
|
||||
console.log('没有可播放的歌曲,关闭播放器')
|
||||
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: '播放失败,已关闭播放器',
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
if (onClose) {
|
||||
onClose()
|
||||
}
|
||||
|
||||
isRetrying.value = false
|
||||
return { handled: true, shouldClose: true }
|
||||
} else {
|
||||
// 非播放列表模式(如投稿页面),只显示错误提示,不自动跳过
|
||||
console.log('播放失败,显示错误提示(非播放列表模式)')
|
||||
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: '歌曲播放失败,请检查链接或稍后重试',
|
||||
duration: 5000
|
||||
})
|
||||
|
||||
isRetrying.value = false
|
||||
return { handled: true, showError: true }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('处理播放错误时发生异常:', err)
|
||||
isRetrying.value = false
|
||||
return { handled: false, error: err }
|
||||
}
|
||||
}
|
||||
|
||||
// 增强的音质切换功能
|
||||
const enhancedQualitySwitch = async (song, newQuality) => {
|
||||
if (!song?.musicPlatform || !song?.musicId) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: '无法切换音质:歌曲信息不完整',
|
||||
duration: 3000
|
||||
})
|
||||
return { success: false, error: '歌曲信息不完整' }
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`尝试切换到音质: ${newQuality}`)
|
||||
|
||||
// 先尝试获取新音质的链接(传入整数音质值)
|
||||
const result = await getMusicUrl(song.musicPlatform, song.musicId, newQuality, {
|
||||
musicInfo: {
|
||||
name: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album || undefined
|
||||
}
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// 保存音质设置
|
||||
saveQuality(song.musicPlatform, newQuality)
|
||||
|
||||
const qualityOptions = getQualityOptions(song.musicPlatform)
|
||||
const qualityLabel =
|
||||
qualityOptions.find((option) => option.value === newQuality)?.label || newQuality
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `已切换到${qualityLabel}`,
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
url: result.url,
|
||||
quality: newQuality,
|
||||
qualityLabel
|
||||
}
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: `切换音质失败: ${result.error}`,
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
return { success: false, error: result.error }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换音质时发生错误:', error)
|
||||
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: '切换音质失败,请稍后重试',
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
// 处理热重载时的清理
|
||||
const handleHotReload = () => {
|
||||
// 停止所有正在播放的音频
|
||||
const audioElements = document.querySelectorAll('audio')
|
||||
audioElements.forEach((audio) => {
|
||||
if (!audio.paused) {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
})
|
||||
|
||||
// 清理全局播放器状态
|
||||
if (typeof window !== 'undefined') {
|
||||
if (window.voiceHubPlayerInstance) {
|
||||
window.voiceHubPlayerInstance = null
|
||||
}
|
||||
|
||||
// 清理鸿蒙侧状态
|
||||
if (window.voiceHubPlayer && window.voiceHubPlayer.onPlayStateChanged) {
|
||||
window.voiceHubPlayer.onPlayStateChanged(false, {
|
||||
position: 0,
|
||||
duration: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log('热重载清理完成')
|
||||
}
|
||||
|
||||
return {
|
||||
retryCount: computed(() => retryCount.value),
|
||||
maxRetries: computed(() => maxRetries.value),
|
||||
isRetrying: computed(() => isRetrying.value),
|
||||
qualitySwitchAttempted: computed(() => qualitySwitchAttempted.value),
|
||||
|
||||
resetRetryState,
|
||||
getMusicUrl,
|
||||
tryQualitySwitch,
|
||||
handlePlaybackError,
|
||||
enhancedQualitySwitch,
|
||||
handleHotReload
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
import { ref } from 'vue'
|
||||
import { useAudioPlayer } from '~/composables/useAudioPlayer'
|
||||
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
|
||||
import { useMusicWebSocket } from '~/composables/useMusicWebSocket'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useLyrics } from '~/composables/useLyrics'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
export const useAudioPlayerSync = () => {
|
||||
const { audioPlayer: audioPlayerLocale } = useLocale()
|
||||
const globalAudioPlayer = useAudioPlayer()
|
||||
const control = useAudioPlayerControl()
|
||||
const musicWebSocket = useMusicWebSocket()
|
||||
const lyrics = useLyrics()
|
||||
|
||||
// 节流控制
|
||||
const lastWebSocketUpdate = ref(0)
|
||||
const lastHarmonyUpdate = ref(0)
|
||||
const lastProgressDiff = ref(0)
|
||||
|
||||
// HarmonyOS 事件处理器引用
|
||||
let harmonyOSHandlers: Record<string, Function> | null = null
|
||||
|
||||
// 检测是否在鸿蒙环境
|
||||
const isHarmonyOS = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
|
||||
// 1. UA 检测(最可靠的初始检测)
|
||||
if (window.navigator.userAgent.includes('HarmonyOS')) return true
|
||||
|
||||
// 2. 桥接对象检测
|
||||
if (window.harmonyOSBridge) return true
|
||||
|
||||
// 3. 播放器对象检测(确保是原生注入的,即包含特定的原生方法)
|
||||
if (window.voiceHubPlayer && typeof window.voiceHubPlayer.onPlayStateChanged === 'function') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 通知鸿蒙应用播放状态变化(改进事件驱动机制)
|
||||
const notifyHarmonyOS = (
|
||||
action: string,
|
||||
extraData: Record<string, any> = {},
|
||||
song?: any,
|
||||
lyrics?: string
|
||||
) => {
|
||||
if (typeof window !== 'undefined' && song) {
|
||||
// 处理封面URL,确保是完整的URL
|
||||
let coverUrl = song.cover || ''
|
||||
|
||||
if (coverUrl && !coverUrl.startsWith('http')) {
|
||||
if (coverUrl.startsWith('/')) {
|
||||
coverUrl = window.location.origin + coverUrl
|
||||
} else {
|
||||
coverUrl = window.location.origin + '/' + coverUrl
|
||||
}
|
||||
}
|
||||
|
||||
// 映射播放模式
|
||||
// HarmonyOS: 0=SEQUENCE, 1=SINGLE, 2=LIST, 3=SHUFFLE
|
||||
let loopMode = 0; // SEQUENCE ('off')
|
||||
if (control.playMode.value === 'loopOne') {
|
||||
loopMode = 1; // SINGLE
|
||||
} else if (control.playMode.value === 'order') {
|
||||
loopMode = 2; // LIST
|
||||
}
|
||||
|
||||
const songInfo = {
|
||||
title: extraData.title || song.title || '',
|
||||
artist: extraData.artist || song.artist || '',
|
||||
album: extraData.album || song.album || '',
|
||||
cover: extraData.artwork || coverUrl,
|
||||
duration: extraData.duration !== undefined ? extraData.duration : 0,
|
||||
position: extraData.position !== undefined ? extraData.position : 0,
|
||||
lyrics: lyrics || '',
|
||||
loopMode: loopMode
|
||||
}
|
||||
|
||||
// 只有在鸿蒙环境中才调用相关API
|
||||
if (window.voiceHubPlayer) {
|
||||
if (action === 'play') {
|
||||
window.voiceHubPlayer.onPlayStateChanged(true, {
|
||||
position: songInfo.position,
|
||||
duration: songInfo.duration,
|
||||
loopMode: songInfo.loopMode
|
||||
})
|
||||
} else if (action === 'pause') {
|
||||
window.voiceHubPlayer.onPlayStateChanged(false, {
|
||||
position: songInfo.position,
|
||||
duration: songInfo.duration,
|
||||
loopMode: songInfo.loopMode
|
||||
})
|
||||
} else if (action === 'stop') {
|
||||
window.voiceHubPlayer.onPlayStateChanged(false, {
|
||||
position: 0,
|
||||
duration: songInfo.duration,
|
||||
loopMode: songInfo.loopMode
|
||||
})
|
||||
} else if (action === 'progress') {
|
||||
// 进度更新时,只更新位置,不改变播放状态
|
||||
if (window.voiceHubPlayer.updatePosition) {
|
||||
window.voiceHubPlayer.updatePosition(songInfo.position)
|
||||
} else {
|
||||
window.voiceHubPlayer.onPlayStateChanged(true, {
|
||||
position: songInfo.position,
|
||||
duration: songInfo.duration,
|
||||
loopMode: songInfo.loopMode
|
||||
})
|
||||
}
|
||||
} else if (action === 'metadata') {
|
||||
// 元数据更新时,只更新歌曲信息,不改变播放状态和位置
|
||||
window.voiceHubPlayer.onSongChanged(songInfo)
|
||||
// 延迟同步播放列表状态,但不更新播放状态
|
||||
setTimeout(() => {
|
||||
notifyPlaylistState()
|
||||
}, 100)
|
||||
} else if (action === 'seek') {
|
||||
window.voiceHubPlayer.onPlayStateChanged(true, {
|
||||
position: songInfo.position,
|
||||
duration: songInfo.duration,
|
||||
loopMode: songInfo.loopMode
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通知播放列表状态变化
|
||||
const notifyPlaylistState = () => {
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
window.voiceHubPlayer &&
|
||||
window.voiceHubPlayer.updatePlaylistState
|
||||
) {
|
||||
const currentIndex = globalAudioPlayer.getCurrentPlaylistIndex().value
|
||||
const playlist = globalAudioPlayer.getCurrentPlaylist().value
|
||||
const hasNext = globalAudioPlayer.hasNext.value
|
||||
const hasPrevious = globalAudioPlayer.hasPrevious.value
|
||||
|
||||
// 映射播放模式
|
||||
// HarmonyOS: 0=SEQUENCE, 1=SINGLE, 2=LIST, 3=SHUFFLE
|
||||
let loopMode = 0; // SEQUENCE ('off')
|
||||
|
||||
if (control.playMode.value === 'loopOne') {
|
||||
loopMode = 1; // SINGLE
|
||||
} else if (control.playMode.value === 'order') {
|
||||
// 为了区分,把 'order' 映射为 2 (LIST),显示列表循环图标。
|
||||
loopMode = 2; // LIST
|
||||
}
|
||||
|
||||
const playlistState = {
|
||||
currentIndex: currentIndex,
|
||||
playlistLength: playlist ? playlist.length : 0,
|
||||
hasNext: hasNext,
|
||||
hasPrevious: hasPrevious,
|
||||
loopMode: loopMode
|
||||
}
|
||||
|
||||
// 播放列表状态更新(减少日志输出)
|
||||
window.voiceHubPlayer.updatePlaylistState(playlistState)
|
||||
|
||||
// 备用方案:直接通过HarmonyOS接口发送
|
||||
if (window.HarmonyOS && window.HarmonyOS.callNative) {
|
||||
window.HarmonyOS.callNative('updatePlaylistState', playlistState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 节流的进度更新
|
||||
const throttledProgressUpdate = (
|
||||
currentTime: number,
|
||||
duration: number,
|
||||
isPlaying: boolean,
|
||||
song?: any
|
||||
) => {
|
||||
if (!isHarmonyOS()) return
|
||||
|
||||
// 只在播放状态下发送进度更新,避免暂停时发送错误的位置信息
|
||||
if (!isPlaying) return
|
||||
|
||||
const now = Date.now()
|
||||
const progressDiff = Math.abs(currentTime - lastProgressDiff.value)
|
||||
|
||||
// WebSocket状态更新(每2秒最多一次)
|
||||
if (now - lastWebSocketUpdate.value > 2000) {
|
||||
lastWebSocketUpdate.value = now
|
||||
|
||||
musicWebSocket.sendStateUpdate({
|
||||
songId: song?.id,
|
||||
isPlaying: isPlaying,
|
||||
position: currentTime,
|
||||
duration: duration,
|
||||
volume: 1,
|
||||
playlistIndex: globalAudioPlayer.getCurrentPlaylistIndex().value
|
||||
})
|
||||
}
|
||||
|
||||
// 鸿蒙应用进度更新(每1秒最多一次,或进度差值超过0.5秒)
|
||||
if (now - lastHarmonyUpdate.value > 1000 || progressDiff > 0.5) {
|
||||
lastHarmonyUpdate.value = now
|
||||
lastProgressDiff.value = currentTime
|
||||
|
||||
// 只更新播放状态和进度,不传递歌曲信息(避免覆盖元数据)
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
window.voiceHubPlayer &&
|
||||
window.voiceHubPlayer.onPlayStateChanged
|
||||
) {
|
||||
// 获取当前循环模式
|
||||
let loopMode = 0;
|
||||
if (control.playMode.value === 'loopOne') {
|
||||
loopMode = 1;
|
||||
} else if (control.playMode.value === 'order') {
|
||||
loopMode = 2;
|
||||
}
|
||||
|
||||
window.voiceHubPlayer.onPlayStateChanged(isPlaying, {
|
||||
position: currentTime,
|
||||
duration: duration,
|
||||
loopMode: loopMode
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket 状态更新
|
||||
const sendWebSocketUpdate = (data: any) => {
|
||||
if (isHarmonyOS()) {
|
||||
musicWebSocket.sendStateUpdate(data)
|
||||
}
|
||||
}
|
||||
|
||||
// 播放上一首歌曲
|
||||
const playPrevious = async (song?: any) => {
|
||||
try {
|
||||
// 检查是否有上一首歌曲
|
||||
if (!globalAudioPlayer.hasPrevious.value) {
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(audioPlayerLocale.value.noPrevious, 'warning')
|
||||
}
|
||||
return { success: false, newSong: null }
|
||||
}
|
||||
|
||||
// 记录当前播放状态 - 优先检查鸿蒙侧状态
|
||||
let wasPlaying = globalAudioPlayer.getPlayingStatus().value
|
||||
|
||||
// 如果在鸿蒙环境,尝试获取鸿蒙侧的播放状态
|
||||
if (isHarmonyOS() && window.voiceHubPlayer && window.voiceHubPlayer.getPlayState) {
|
||||
try {
|
||||
wasPlaying = window.voiceHubPlayer.getPlayState()
|
||||
} catch (e) {
|
||||
console.warn('无法获取鸿蒙侧播放状态,使用Web端状态')
|
||||
}
|
||||
}
|
||||
|
||||
const success = await globalAudioPlayer.playPrevious()
|
||||
|
||||
if (success) {
|
||||
const newSong = globalAudioPlayer.getCurrentSong().value
|
||||
if (newSong) {
|
||||
// 只在鸿蒙环境下发送WebSocket消息
|
||||
if (isHarmonyOS()) {
|
||||
await musicWebSocket.sendSongChange({
|
||||
songId: newSong.id,
|
||||
title: newSong.title,
|
||||
artist: newSong.artist,
|
||||
cover: newSong.cover || '',
|
||||
duration: 0,
|
||||
playlistIndex: globalAudioPlayer.getCurrentPlaylistIndex().value
|
||||
})
|
||||
|
||||
// 获取新歌曲的歌词(但不立即设置元数据,让AudioPlayer的handleLoaded处理)
|
||||
if (newSong.musicPlatform && newSong.musicId) {
|
||||
await lyrics.fetchLyrics(newSong.musicPlatform, newSong.musicId, {
|
||||
title: newSong.title,
|
||||
artist: newSong.artist,
|
||||
album: newSong.album
|
||||
})
|
||||
}
|
||||
|
||||
// 如果之前是播放状态,切换歌曲后自动开始播放
|
||||
// 不在这里传递歌词,让AudioPlayer的handleLoaded方法负责歌词传递
|
||||
if (wasPlaying) {
|
||||
setTimeout(() => {
|
||||
notifyHarmonyOS(
|
||||
'play',
|
||||
{
|
||||
position: 0,
|
||||
duration: newSong.duration || 0
|
||||
},
|
||||
newSong
|
||||
) // 移除歌词参数,避免与handleLoaded竞争
|
||||
}, 500) // 延迟确保handleLoaded完成元数据设置
|
||||
}
|
||||
|
||||
// 通知播放列表状态变化
|
||||
setTimeout(() => {
|
||||
notifyPlaylistState()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return { success: true, newSong }
|
||||
}
|
||||
} else {
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(audioPlayerLocale.value.previousFailed, 'error')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('播放上一首歌曲失败:', error)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(audioPlayerLocale.value.previousFailed, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, newSong: null }
|
||||
}
|
||||
|
||||
// 获取音乐链接
|
||||
const getMusicUrl = async (platform, musicId, quality) => {
|
||||
try {
|
||||
let apiUrl
|
||||
if (platform === 'netease') {
|
||||
apiUrl = `https://api.vkeys.cn/v2/music/netease?id=${musicId}&quality=${quality}`
|
||||
} else if (platform === 'tencent') {
|
||||
const idParam = /^\d+$/.test(String(musicId)) ? `id=${musicId}` : `mid=${musicId}`
|
||||
apiUrl = `https://api.vkeys.cn/v2/music/tencent?${idParam}&quality=${quality}`
|
||||
} else {
|
||||
return { success: false, error: '不支持的音乐平台' }
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: `获取音乐URL失败: ${response.status}` }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200 && data.data && data.data.url) {
|
||||
// 将HTTP URL改为HTTPS
|
||||
let url = data.data.url
|
||||
if (url.startsWith('http://')) {
|
||||
url = url.replace('http://', 'https://')
|
||||
}
|
||||
return { success: true, url }
|
||||
} else {
|
||||
return { success: false, error: data.message || `API错误: ${data.code}` }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取音乐链接失败:', error)
|
||||
return { success: false, error: error.message || '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
// 播放下一首歌曲
|
||||
const playNext = async (song?: any) => {
|
||||
try {
|
||||
// 检查是否有下一首歌曲
|
||||
if (!globalAudioPlayer.hasNext.value) {
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(audioPlayerLocale.value.noNext, 'warning')
|
||||
}
|
||||
return { success: false, newSong: null }
|
||||
}
|
||||
|
||||
// 记录当前播放状态 - 优先检查鸿蒙侧状态
|
||||
let wasPlaying = globalAudioPlayer.getPlayingStatus().value
|
||||
|
||||
// 如果在鸿蒙环境,尝试获取鸿蒙侧的播放状态
|
||||
if (isHarmonyOS() && window.voiceHubPlayer && window.voiceHubPlayer.getPlayState) {
|
||||
try {
|
||||
wasPlaying = window.voiceHubPlayer.getPlayState()
|
||||
} catch (e) {
|
||||
console.warn('无法获取鸿蒙侧播放状态,使用Web端状态')
|
||||
}
|
||||
}
|
||||
|
||||
const success = await globalAudioPlayer.playNext()
|
||||
|
||||
if (success) {
|
||||
const newSong = globalAudioPlayer.getCurrentSong().value
|
||||
if (newSong) {
|
||||
// 只在鸿蒙环境下发送WebSocket消息
|
||||
if (isHarmonyOS()) {
|
||||
await musicWebSocket.sendSongChange({
|
||||
songId: newSong.id,
|
||||
title: newSong.title,
|
||||
artist: newSong.artist,
|
||||
cover: newSong.cover || '',
|
||||
duration: 0,
|
||||
playlistIndex: globalAudioPlayer.getCurrentPlaylistIndex().value
|
||||
})
|
||||
|
||||
// 获取新歌曲的歌词(但不立即设置元数据,让AudioPlayer的handleLoaded处理)
|
||||
if (newSong.musicPlatform && newSong.musicId) {
|
||||
await lyrics.fetchLyrics(newSong.musicPlatform, newSong.musicId, {
|
||||
title: newSong.title,
|
||||
artist: newSong.artist,
|
||||
album: newSong.album
|
||||
})
|
||||
}
|
||||
|
||||
// 如果之前是播放状态,切换歌曲后自动开始播放
|
||||
// 不在这里传递歌词,让AudioPlayer的handleLoaded方法负责歌词传递
|
||||
if (wasPlaying) {
|
||||
setTimeout(() => {
|
||||
notifyHarmonyOS(
|
||||
'play',
|
||||
{
|
||||
position: 0,
|
||||
duration: newSong.duration || 0
|
||||
},
|
||||
newSong
|
||||
) // 移除歌词参数,避免与handleLoaded竞争
|
||||
}, 500) // 延迟确保handleLoaded完成元数据设置
|
||||
}
|
||||
|
||||
// 通知播放列表状态变化
|
||||
setTimeout(() => {
|
||||
notifyPlaylistState()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return { success: true, newSong }
|
||||
}
|
||||
} else {
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(audioPlayerLocale.value.nextFailed, 'error')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('播放下一首歌曲失败:', error)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(audioPlayerLocale.value.nextFailed, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, newSong: null }
|
||||
}
|
||||
|
||||
// 同步播放状态到全局
|
||||
const syncPlayStateToGlobal = (isPlaying: boolean, song?: any) => {
|
||||
if (isPlaying && song) {
|
||||
globalAudioPlayer.playSong(song)
|
||||
} else {
|
||||
globalAudioPlayer.pauseSong()
|
||||
}
|
||||
}
|
||||
|
||||
// 同步停止状态到全局
|
||||
const syncStopToGlobal = () => {
|
||||
globalAudioPlayer.stopSong()
|
||||
}
|
||||
|
||||
// 更新全局播放位置
|
||||
const updateGlobalPosition = (position: number, duration?: number) => {
|
||||
globalAudioPlayer.updatePosition(position)
|
||||
if (duration !== undefined) {
|
||||
globalAudioPlayer.setDuration(duration)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置全局播放列表
|
||||
const setGlobalPlaylist = (song: any, playlist: any[]) => {
|
||||
if (song && playlist && playlist.length > 0) {
|
||||
globalAudioPlayer.playSong(song, playlist)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 WebSocket 连接
|
||||
const initializeWebSocket = () => {
|
||||
if (isHarmonyOS()) {
|
||||
const { getToken } = useAuth()
|
||||
const token = getToken()
|
||||
musicWebSocket.connect(token || undefined)
|
||||
|
||||
// 设置WebSocket事件监听器
|
||||
musicWebSocket.setStateUpdateListener((state) => {
|
||||
// 处理来自其他客户端的状态更新
|
||||
})
|
||||
|
||||
musicWebSocket.setSongChangeListener((songInfo) => {
|
||||
// 处理歌曲切换通知
|
||||
console.log('Received song change notification:', songInfo)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化鸿蒙系统控制事件
|
||||
const initializeHarmonyOSControls = (callbacks: {
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onStop: () => void
|
||||
onNext: () => void
|
||||
onPrevious: () => void
|
||||
onSeek: (time: number) => void
|
||||
onPositionUpdate?: (time: number) => void
|
||||
}) => {
|
||||
// 只有在检测到鸿蒙环境时才初始化
|
||||
if (!isHarmonyOS()) return
|
||||
|
||||
const handleHarmonyOSPlay = () => {
|
||||
callbacks.onPlay()
|
||||
}
|
||||
|
||||
const handleHarmonyOSPause = () => {
|
||||
callbacks.onPause()
|
||||
}
|
||||
|
||||
const handleHarmonyOSStop = () => {
|
||||
callbacks.onStop()
|
||||
}
|
||||
|
||||
const handleHarmonyOSNext = () => {
|
||||
callbacks.onNext()
|
||||
}
|
||||
|
||||
const handleHarmonyOSPrevious = () => {
|
||||
callbacks.onPrevious()
|
||||
}
|
||||
|
||||
const handleHarmonyOSSeek = (event: CustomEvent) => {
|
||||
const time = event.detail?.time || event.time
|
||||
if (typeof time === 'number') {
|
||||
callbacks.onSeek(time)
|
||||
}
|
||||
}
|
||||
|
||||
const handleHarmonyOSPositionUpdate = (event: CustomEvent) => {
|
||||
const time = event.detail?.time || event.time
|
||||
if (typeof time === 'number' && callbacks.onPositionUpdate) {
|
||||
callbacks.onPositionUpdate(time)
|
||||
}
|
||||
}
|
||||
|
||||
const handleHarmonyOSSetLoopMode = (event: CustomEvent) => {
|
||||
const mode = event.detail?.mode;
|
||||
|
||||
// 处理数字模式(HarmonyOS原生值)或字符串模式
|
||||
// HarmonyOS: 0=SEQUENCE, 1=SINGLE, 2=LIST, 3=SHUFFLE
|
||||
// Web: 'off', 'loopOne', 'order'
|
||||
let targetMode: 'off' | 'order' | 'loopOne' | null = null;
|
||||
|
||||
if (typeof mode === 'number') {
|
||||
if (mode === 1) { // SINGLE
|
||||
targetMode = 'loopOne';
|
||||
} else if (mode === 2) { // LIST
|
||||
// 映射为列表模式
|
||||
targetMode = 'order';
|
||||
} else if (mode === 0) { // SEQUENCE
|
||||
targetMode = 'off';
|
||||
} else if (mode === 3) { // SHUFFLE
|
||||
targetMode = 'order';
|
||||
}
|
||||
} else if (typeof mode === 'string') {
|
||||
if (['off', 'order', 'loopOne'].includes(mode)) {
|
||||
targetMode = mode as 'off' | 'order' | 'loopOne';
|
||||
} else if (mode === 'shuffle') {
|
||||
targetMode = 'order';
|
||||
}
|
||||
}
|
||||
|
||||
if (targetMode) {
|
||||
control.setPlayMode(targetMode);
|
||||
// 立即通知状态更新
|
||||
notifyPlaylistState();
|
||||
}
|
||||
}
|
||||
|
||||
// 保存处理函数引用以便清理
|
||||
harmonyOSHandlers = {
|
||||
handleHarmonyOSPlay,
|
||||
handleHarmonyOSPause,
|
||||
handleHarmonyOSStop,
|
||||
handleHarmonyOSNext,
|
||||
handleHarmonyOSPrevious,
|
||||
handleHarmonyOSSeek,
|
||||
handleHarmonyOSPositionUpdate,
|
||||
handleHarmonyOSSetLoopMode
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
window.addEventListener('harmonyos-play', handleHarmonyOSPlay)
|
||||
window.addEventListener('harmonyos-pause', handleHarmonyOSPause)
|
||||
window.addEventListener('harmonyos-stop', handleHarmonyOSStop)
|
||||
window.addEventListener('harmonyos-next', handleHarmonyOSNext)
|
||||
window.addEventListener('harmonyos-previous', handleHarmonyOSPrevious)
|
||||
window.addEventListener('harmonyos-seek', handleHarmonyOSSeek as EventListener)
|
||||
window.addEventListener('harmonyos-position-update',
|
||||
handleHarmonyOSPositionUpdate as EventListener
|
||||
)
|
||||
window.addEventListener('harmonyos-set-loop-mode', handleHarmonyOSSetLoopMode as EventListener)
|
||||
|
||||
// 增强:直接暴露方法给 window.voiceHubPlayer,确保 Index.ets 可以直接调用
|
||||
if (typeof window !== 'undefined') {
|
||||
// 确保对象存在
|
||||
window.voiceHubPlayer = window.voiceHubPlayer || {};
|
||||
|
||||
// 添加/覆盖控制方法
|
||||
// 注意:我们不覆盖 onPlayStateChanged 和 onSongChanged,因为它们可能是由 Index.ets 注入的
|
||||
// 我们只添加控制方法,这样 Index.ets 可以直接调用它们
|
||||
const methodsToAdd = {
|
||||
play: handleHarmonyOSPlay,
|
||||
pause: handleHarmonyOSPause,
|
||||
stop: handleHarmonyOSStop,
|
||||
next: handleHarmonyOSNext, // Index.ets 使用 next()
|
||||
playNext: handleHarmonyOSNext, // 兼容性
|
||||
previous: handleHarmonyOSPrevious, // Index.ets 使用 previous()
|
||||
playPrevious: handleHarmonyOSPrevious, // 兼容性
|
||||
seek: (time: number) => callbacks.onSeek(time),
|
||||
setLoopMode: (mode: number | string) => {
|
||||
// 模拟事件对象调用处理函数
|
||||
const event = { detail: { mode } } as CustomEvent;
|
||||
handleHarmonyOSSetLoopMode(event);
|
||||
}
|
||||
};
|
||||
|
||||
Object.assign(window.voiceHubPlayer, methodsToAdd);
|
||||
|
||||
// 同时也尝试暴露给 voiceHubPlayerInstance (Index.ets 的备用方案)
|
||||
if (window.voiceHubPlayerInstance) {
|
||||
Object.assign(window.voiceHubPlayerInstance, {
|
||||
setPlayMode: (mode: number | string) => {
|
||||
const event = { detail: { mode } } as CustomEvent;
|
||||
handleHarmonyOSSetLoopMode(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('VoiceHub: HarmonyOS direct control methods attached');
|
||||
}
|
||||
}
|
||||
|
||||
// 清理鸿蒙系统控制事件
|
||||
const cleanupHarmonyOSControls = () => {
|
||||
if (harmonyOSHandlers) {
|
||||
window.removeEventListener(
|
||||
'harmonyos-play',
|
||||
harmonyOSHandlers.handleHarmonyOSPlay as EventListener
|
||||
)
|
||||
window.removeEventListener(
|
||||
'harmonyos-pause',
|
||||
harmonyOSHandlers.handleHarmonyOSPause as EventListener
|
||||
)
|
||||
window.removeEventListener(
|
||||
'harmonyos-stop',
|
||||
harmonyOSHandlers.handleHarmonyOSStop as EventListener
|
||||
)
|
||||
window.removeEventListener(
|
||||
'harmonyos-next',
|
||||
harmonyOSHandlers.handleHarmonyOSNext as EventListener
|
||||
)
|
||||
window.removeEventListener(
|
||||
'harmonyos-previous',
|
||||
harmonyOSHandlers.handleHarmonyOSPrevious as EventListener
|
||||
)
|
||||
window.removeEventListener(
|
||||
'harmonyos-seek',
|
||||
harmonyOSHandlers.handleHarmonyOSSeek as EventListener
|
||||
)
|
||||
window.removeEventListener(
|
||||
'harmonyos-position-update',
|
||||
harmonyOSHandlers.handleHarmonyOSPositionUpdate as EventListener
|
||||
)
|
||||
window.removeEventListener(
|
||||
'harmonyos-set-loop-mode',
|
||||
harmonyOSHandlers.handleHarmonyOSSetLoopMode as EventListener
|
||||
)
|
||||
harmonyOSHandlers = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
globalAudioPlayer,
|
||||
lyrics,
|
||||
|
||||
// 鸿蒙通知
|
||||
notifyHarmonyOS,
|
||||
notifyPlaylistState,
|
||||
|
||||
// 节流更新
|
||||
throttledProgressUpdate,
|
||||
sendWebSocketUpdate,
|
||||
|
||||
// 播放控制
|
||||
playPrevious,
|
||||
playNext,
|
||||
|
||||
// 全局状态同步
|
||||
syncPlayStateToGlobal,
|
||||
syncStopToGlobal,
|
||||
updateGlobalPosition,
|
||||
setGlobalPlaylist,
|
||||
|
||||
// 初始化和清理
|
||||
initializeWebSocket,
|
||||
initializeHarmonyOSControls,
|
||||
cleanupHarmonyOSControls,
|
||||
|
||||
// 工具函数
|
||||
isHarmonyOS
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { computed, readonly, ref, watch } from 'vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { getLoginStatus } from '~/utils/neteaseApi'
|
||||
|
||||
// 音质配置
|
||||
export const QUALITY_OPTIONS = {
|
||||
netease: [
|
||||
{ value: 2, key: 'standard' },
|
||||
{ value: 4, key: 'neteaseHq' },
|
||||
{ value: 5, key: 'neteaseSq' },
|
||||
{ value: 6, key: 'neteaseHiRes' },
|
||||
{ value: 9, key: 'neteaseMaster' }
|
||||
],
|
||||
tencent: [
|
||||
{ value: 4, key: 'standard' },
|
||||
{ value: 8, key: 'tencentHq' },
|
||||
{ value: 10, key: 'tencentSq' },
|
||||
{ value: 11, key: 'tencentHiRes' },
|
||||
{ value: 14, key: 'tencentMaster' }
|
||||
],
|
||||
bilibili: [{ value: 1, key: 'bilibiliDefault' }],
|
||||
migu: [
|
||||
{ value: 1, key: 'standard' },
|
||||
{ value: 2, key: 'miguHq' },
|
||||
{ value: 3, key: 'miguSq' },
|
||||
{ value: 4, key: 'miguZq24' },
|
||||
]
|
||||
}
|
||||
|
||||
// 默认音质设置
|
||||
const DEFAULT_QUALITY = {
|
||||
netease: 4, // HQ极高 (320k)
|
||||
tencent: 8, // HQ高音质
|
||||
bilibili: 1,
|
||||
migu: 1 // 标准音质(咪咕匿名仅提供 128k)
|
||||
}
|
||||
|
||||
// 全局音质状态,确保所有组件共享同一个状态
|
||||
let globalAudioQuality: any = null
|
||||
// 网易云登录状态,全局共享
|
||||
const isNeteaseLoggedIn = ref(false)
|
||||
let isLoginStatusInitialized = false
|
||||
|
||||
// 网易 VIP 标志(localStorage netease_vip):vipType 非 0 即 VIP,仅 VIP 登录态才优先官方播放源
|
||||
const persistNeteaseVipFlag = (profile: any) => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
if (profile && typeof profile.vipType === 'number') {
|
||||
localStorage.setItem('netease_vip', profile.vipType !== 0 ? '1' : '0')
|
||||
} else {
|
||||
localStorage.removeItem('netease_vip')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[audioQuality] Failed to persist netease_vip:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const clearNeteaseVipFlag = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
localStorage.removeItem('netease_vip')
|
||||
} catch {
|
||||
// localStorage 不可用时忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 每会话一次的 VIP 状态静默刷新守卫
|
||||
let isNeteaseVipRefreshing = false
|
||||
let isNeteaseVipRefreshed = false
|
||||
|
||||
// 静默刷新 VIP 状态:有 cookie 时调 /login/status 校验登录态并同步 VIP 标志
|
||||
const refreshNeteaseVipStatus = async () => {
|
||||
if (typeof window === 'undefined' || isNeteaseVipRefreshed || isNeteaseVipRefreshing) return
|
||||
let cookie: string | null = null
|
||||
try {
|
||||
cookie = localStorage.getItem('netease_cookie')
|
||||
} catch {
|
||||
// localStorage 不可用时跳过刷新
|
||||
}
|
||||
if (!cookie) return
|
||||
isNeteaseVipRefreshing = true
|
||||
try {
|
||||
const res = await getLoginStatus(cookie)
|
||||
const dataObj = res.body?.data || res.body
|
||||
if (dataObj && dataObj.account) {
|
||||
isNeteaseLoggedIn.value = true
|
||||
persistNeteaseVipFlag(dataObj.profile)
|
||||
} else {
|
||||
// 登录失效:仅清 VIP 标志,cookie 与组件本地状态由组件级校验负责清理
|
||||
clearNeteaseVipFlag()
|
||||
isNeteaseLoggedIn.value = false
|
||||
}
|
||||
} catch {
|
||||
// 网络异常保留本地状态
|
||||
} finally {
|
||||
isNeteaseVipRefreshing = false
|
||||
isNeteaseVipRefreshed = true
|
||||
}
|
||||
}
|
||||
|
||||
export function useAudioQuality() {
|
||||
const { ui } = useLocale()
|
||||
const locale = computed(() => ui.value?.audioQuality || {})
|
||||
|
||||
// 检查网易云登录状态
|
||||
const checkNeteaseLoginStatus = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
const cookie = localStorage.getItem('netease_cookie')
|
||||
isNeteaseLoggedIn.value = !!cookie
|
||||
}
|
||||
|
||||
// 从localStorage读取音质设置
|
||||
const getStoredQuality = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem('audioQuality')
|
||||
return stored ? JSON.parse(stored) : DEFAULT_QUALITY
|
||||
} catch {
|
||||
return DEFAULT_QUALITY
|
||||
}
|
||||
}
|
||||
|
||||
// 使用全局状态,确保所有组件共享
|
||||
if (!globalAudioQuality) {
|
||||
globalAudioQuality = ref(getStoredQuality())
|
||||
|
||||
// 监听状态变化,自动保存到localStorage
|
||||
watch(
|
||||
globalAudioQuality,
|
||||
(newValue) => {
|
||||
try {
|
||||
localStorage.setItem('audioQuality', JSON.stringify(newValue))
|
||||
} catch (error) {
|
||||
console.error('[audioQuality] Failed to save quality settings:', error)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
}
|
||||
|
||||
// 初始化登录状态监听
|
||||
if (!isLoginStatusInitialized && typeof window !== 'undefined') {
|
||||
checkNeteaseLoginStatus()
|
||||
refreshNeteaseVipStatus()
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === 'netease_cookie') {
|
||||
checkNeteaseLoginStatus()
|
||||
// 其他标签页登录态变化时重新同步 VIP 标志
|
||||
isNeteaseVipRefreshed = false
|
||||
refreshNeteaseVipStatus()
|
||||
}
|
||||
})
|
||||
isLoginStatusInitialized = true
|
||||
}
|
||||
|
||||
const audioQuality = globalAudioQuality
|
||||
|
||||
// 保存音质设置到localStorage
|
||||
const saveQuality = (platform: string, quality: number) => {
|
||||
audioQuality.value[platform] = quality
|
||||
// localStorage保存已经通过watch自动处理
|
||||
}
|
||||
|
||||
// 获取指定平台的音质设置
|
||||
const getQuality = (platform: string) => {
|
||||
// 处理 netease-podcast 别名
|
||||
if (platform === 'netease-podcast') {
|
||||
platform = 'netease'
|
||||
}
|
||||
|
||||
const stored = audioQuality.value[platform]
|
||||
// 已保存的音质值不再存在于选项列表时(如咪咕音质收敛后残留旧值),回落默认值
|
||||
const platformOptions =
|
||||
(QUALITY_OPTIONS as Record<string, Array<{ value: number; key: string }>>)[platform] || []
|
||||
const isValid = platformOptions.some((option) => option.value === stored)
|
||||
if (isValid) {
|
||||
return stored
|
||||
}
|
||||
return (DEFAULT_QUALITY as Record<string, number>)[platform]
|
||||
}
|
||||
|
||||
// 获取指定平台的音质选项
|
||||
const getQualityOptions = (platform: string) => {
|
||||
// 处理 netease-podcast 别名
|
||||
if (platform === 'netease-podcast') {
|
||||
platform = 'netease'
|
||||
}
|
||||
|
||||
return (QUALITY_OPTIONS[platform] || []).map((option) => ({
|
||||
...option,
|
||||
label: locale.value?.options?.[option.key]?.label || option.key,
|
||||
description: locale.value?.options?.[option.key]?.description || '使用推荐音质设置'
|
||||
}))
|
||||
}
|
||||
|
||||
// 获取音质标签
|
||||
const getQualityLabel = (platform: string, quality: number) => {
|
||||
// 处理 netease-podcast 别名
|
||||
if (platform === 'netease-podcast') {
|
||||
platform = 'netease'
|
||||
}
|
||||
const options = getQualityOptions(platform)
|
||||
const option = options.find((opt) => opt.value === quality)
|
||||
return option ? option.label : locale.value.unknown
|
||||
}
|
||||
|
||||
// 获取音质描述
|
||||
const getQualityDescription = (platform: string, quality: number) => {
|
||||
// 处理 netease-podcast 别名
|
||||
if (platform === 'netease-podcast') {
|
||||
platform = 'netease'
|
||||
}
|
||||
const options = getQualityOptions(platform)
|
||||
const option = options.find((opt) => opt.value === quality)
|
||||
return option ? option.description : ''
|
||||
}
|
||||
|
||||
// 计算属性:当前音质设置的可读文本
|
||||
const currentQualityText = computed(() => {
|
||||
const netease = getQualityLabel('netease', getQuality('netease'))
|
||||
const tencent = getQualityLabel('tencent', getQuality('tencent'))
|
||||
return {
|
||||
netease,
|
||||
tencent
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
audioQuality: readonly(audioQuality),
|
||||
saveQuality,
|
||||
getQuality,
|
||||
getQualityOptions,
|
||||
getQualityLabel,
|
||||
getQualityDescription,
|
||||
currentQualityText,
|
||||
QUALITY_OPTIONS,
|
||||
DEFAULT_QUALITY,
|
||||
checkNeteaseLoginStatus,
|
||||
isNeteaseLoggedIn: readonly(isNeteaseLoggedIn),
|
||||
persistNeteaseVipFlag,
|
||||
clearNeteaseVipFlag
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
// WeakMap 用于存储每个 HTMLMediaElement 的 MediaElementAudioSourceNode
|
||||
// 防止为同一元素创建多个源导致报错
|
||||
const sourceCache = new WeakMap<HTMLMediaElement, MediaElementAudioSourceNode>()
|
||||
|
||||
// 全局 AudioContext(懒加载)以避免"AudioContext 过多"错误
|
||||
let globalAudioContext: AudioContext | null = null
|
||||
|
||||
export const useAudioVisualizer = () => {
|
||||
const audioContext = ref<AudioContext | null>(null)
|
||||
const analyser = ref<AnalyserNode | null>(null)
|
||||
const source = ref<MediaElementAudioSourceNode | null>(null)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
const getAudioContext = () => {
|
||||
if (!globalAudioContext) {
|
||||
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext
|
||||
if (AudioContextClass) {
|
||||
globalAudioContext = new AudioContextClass()
|
||||
}
|
||||
}
|
||||
return globalAudioContext
|
||||
}
|
||||
|
||||
const initialize = (audioElement: HTMLMediaElement) => {
|
||||
if (!audioElement) return
|
||||
|
||||
// 如果已经初始化过同一个音频元素,跳过
|
||||
if (isInitialized.value && source.value && sourceCache.has(audioElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext()
|
||||
if (!ctx) {
|
||||
console.warn('不支持 AudioContext')
|
||||
return
|
||||
}
|
||||
audioContext.value = ctx
|
||||
|
||||
// 创建分析器
|
||||
if (!analyser.value) {
|
||||
analyser.value = ctx.createAnalyser()
|
||||
analyser.value.fftSize = 256 // 根据需要调整分辨率
|
||||
analyser.value.smoothingTimeConstant = 0.8
|
||||
}
|
||||
|
||||
// 检查是否已经有缓存的源
|
||||
if (sourceCache.has(audioElement)) {
|
||||
const cachedSource = sourceCache.get(audioElement)!
|
||||
source.value = cachedSource
|
||||
|
||||
// 尝试重新连接
|
||||
try {
|
||||
// 先断开所有现有连接
|
||||
try {
|
||||
cachedSource.disconnect()
|
||||
} catch (e) {
|
||||
// 可能已经断开
|
||||
}
|
||||
|
||||
// 重新连接:源 -> 分析器 -> 目标
|
||||
cachedSource.connect(analyser.value)
|
||||
// 先断开分析器现有输出,避免重复连接导致信号叠加
|
||||
try {
|
||||
analyser.value.disconnect()
|
||||
} catch (e) {
|
||||
// 可能尚未连接
|
||||
}
|
||||
analyser.value.connect(ctx.destination)
|
||||
isInitialized.value = true
|
||||
} catch (e) {
|
||||
console.warn('[AudioVisualizer] 重新连接失败,可能是不支持 CORS 的音频源,跳过可视化')
|
||||
// 如果重新连接失败,尝试恢复到直接输出
|
||||
try {
|
||||
cachedSource.disconnect()
|
||||
cachedSource.connect(ctx.destination)
|
||||
} catch (e2) {
|
||||
console.error('[AudioVisualizer] 恢复直接输出也失败:', e2)
|
||||
}
|
||||
isInitialized.value = false
|
||||
}
|
||||
} else {
|
||||
// 创建新的 MediaElementSource
|
||||
// 注意:这会断开音频元素的默认输出连接
|
||||
try {
|
||||
const src = ctx.createMediaElementSource(audioElement)
|
||||
sourceCache.set(audioElement, src)
|
||||
source.value = src
|
||||
|
||||
// 连接链:源 -> 分析器 -> 目标
|
||||
src.connect(analyser.value)
|
||||
// 先断开分析器现有输出,避免重复连接导致信号叠加
|
||||
try {
|
||||
analyser.value.disconnect()
|
||||
} catch (e) {
|
||||
// 可能尚未连接
|
||||
}
|
||||
analyser.value.connect(ctx.destination)
|
||||
|
||||
isInitialized.value = true
|
||||
} catch (e) {
|
||||
console.warn('[AudioVisualizer] 创建 MediaElementSource 失败,可能是不支持 CORS 的音频源(如咪咕音乐),跳过可视化')
|
||||
// createMediaElementSource 失败时,音频元素的默认连接应该保持不变
|
||||
// 不需要额外处理
|
||||
isInitialized.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果上下文被挂起(浏览器策略),恢复它
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化音频可视化失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getFrequencyData = () => {
|
||||
if (!analyser.value) return new Uint8Array(0)
|
||||
const dataArray = new Uint8Array(analyser.value.frequencyBinCount)
|
||||
analyser.value.getByteFrequencyData(dataArray)
|
||||
return dataArray
|
||||
}
|
||||
|
||||
const dispose = () => {
|
||||
// 当组件卸载或关闭时,我们需要清理分析器连接
|
||||
// 但必须保持音频播放,所以需要将 source 直接连回 destination
|
||||
if (source.value) {
|
||||
try {
|
||||
// 断开所有连接(包括连向 analyser 的)
|
||||
source.value.disconnect()
|
||||
|
||||
// 恢复直接连接到输出设备,确保声音继续播放
|
||||
if (audioContext.value) {
|
||||
source.value.connect(audioContext.value.destination)
|
||||
} else if (globalAudioContext) {
|
||||
source.value.connect(globalAudioContext.destination)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('清理过程出错:', e)
|
||||
}
|
||||
}
|
||||
isInitialized.value = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
initialize,
|
||||
getFrequencyData,
|
||||
dispose,
|
||||
isInitialized,
|
||||
analyser
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { navigateTo, useState } from '#app'
|
||||
import type { User } from '~/types'
|
||||
import { useUserFilters } from '~/composables/useUserFilters'
|
||||
import { useServerErrors } from '~/composables/useLocaleText'
|
||||
|
||||
interface LoginResponse {
|
||||
success: boolean
|
||||
user?: User
|
||||
requires2FA?: boolean
|
||||
userId?: number
|
||||
methods?: string[]
|
||||
tempToken?: string // 预认证临时令牌
|
||||
}
|
||||
|
||||
interface PasswordChangeResponse {
|
||||
passwordChangedAt?: string
|
||||
}
|
||||
|
||||
const AUTH_STATE_TTL_MS = 30 * 1000
|
||||
|
||||
export const useAuth = () => {
|
||||
const user = useState<User | null>('user', () => null)
|
||||
const token = useState<string | null>('token', () => null)
|
||||
const isAuthenticated = useState<boolean>('isAuthenticated', () => false)
|
||||
const isAdmin = useState<boolean>('isAdmin', () => false)
|
||||
const loading = useState<boolean>('loading', () => false)
|
||||
const { localize: localizeServerError } = useServerErrors()
|
||||
const lastAuthVerifiedAt = useState<number>('lastAuthVerifiedAt', () => 0)
|
||||
|
||||
const clearAuthState = () => {
|
||||
user.value = null
|
||||
token.value = null
|
||||
isAuthenticated.value = false
|
||||
isAdmin.value = false
|
||||
lastAuthVerifiedAt.value = 0
|
||||
}
|
||||
|
||||
const setAuthState = (loggedInUser: User) => {
|
||||
token.value = 'cookie-based'
|
||||
user.value = loggedInUser
|
||||
isAuthenticated.value = true
|
||||
isAdmin.value = ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(loggedInUser.role)
|
||||
lastAuthVerifiedAt.value = Date.now()
|
||||
}
|
||||
|
||||
const initAuth = async (forceRefresh = false) => {
|
||||
// 客户端执行
|
||||
if (typeof window === 'undefined' || import.meta.server) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 短时间内复用认证结果,避免每次客户端导航都串行查询数据库。
|
||||
if (
|
||||
!forceRefresh &&
|
||||
isAuthenticated.value &&
|
||||
user.value &&
|
||||
Date.now() - lastAuthVerifiedAt.value < AUTH_STATE_TTL_MS
|
||||
) {
|
||||
return user.value
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await $fetch<{ user: User }>('/api/auth/verify', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
})
|
||||
|
||||
if (data && data.user) {
|
||||
setAuthState(data.user)
|
||||
return data.user
|
||||
} else {
|
||||
clearAuthState()
|
||||
return null
|
||||
}
|
||||
} catch (error: any) {
|
||||
const hadAuth = isAuthenticated.value
|
||||
|
||||
// 只有当之前是已认证状态,且接口明确返回401(Token无效/过期),才进行清理和跳转
|
||||
if (hadAuth && error.statusCode === 401) {
|
||||
clearAuthState()
|
||||
// Token失效,重定向到登录页
|
||||
await navigateTo('/login?error=SessionExpired')
|
||||
} else if (!hadAuth && error.statusCode === 401) {
|
||||
// 未登录状态下的 401,仅确保状态清理,不跳转
|
||||
clearAuthState()
|
||||
} else if (hadAuth) {
|
||||
// 临时网络故障时短暂保留现有状态,避免每次导航重复冲击认证接口。
|
||||
lastAuthVerifiedAt.value = Date.now()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const response = await $fetch<LoginResponse>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username, password }
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
if (response.requires2FA) {
|
||||
return response
|
||||
}
|
||||
|
||||
if (response.user) {
|
||||
setAuthState(response.user)
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('登录响应格式错误')
|
||||
}
|
||||
|
||||
const verify2FA = async (
|
||||
userId: number,
|
||||
code: string,
|
||||
type: 'totp' | 'email',
|
||||
tempToken?: string
|
||||
) => {
|
||||
const response = await $fetch<{ success: boolean; user: User }>('/api/auth/2fa/verify', {
|
||||
method: 'POST',
|
||||
body: { userId, code, type, token: tempToken }
|
||||
})
|
||||
|
||||
if (response.success && response.user) {
|
||||
setAuthState(response.user)
|
||||
return response
|
||||
}
|
||||
throw new Error('验证失败')
|
||||
}
|
||||
|
||||
const refreshUser = async () => {
|
||||
const data = await $fetch<{ user: User }>('/api/auth/verify')
|
||||
if (data?.user) {
|
||||
setAuthState(data.user)
|
||||
return data.user
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const syncPasswordChangeState = async (
|
||||
response: PasswordChangeResponse,
|
||||
refreshFailureMessage: string
|
||||
) => {
|
||||
if (user.value) {
|
||||
user.value.requirePasswordChange = false
|
||||
user.value.forcePasswordChange = false
|
||||
user.value.passwordChangedAt = response.passwordChangedAt || null
|
||||
user.value.hasSetPassword = true
|
||||
user.value.needsInitialPasswordSetup = false
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshUser()
|
||||
} catch (error) {
|
||||
console.warn(refreshFailureMessage, error)
|
||||
}
|
||||
}
|
||||
|
||||
const changePassword = async (currentPassword: string, newPassword: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch<PasswordChangeResponse>('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: { currentPassword, newPassword }
|
||||
})
|
||||
await syncPasswordChangeState(response, '密码修改成功,但刷新用户状态失败:')
|
||||
} catch (error: any) {
|
||||
throw new Error(localizeServerError(error, '密码修改失败,请重试'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setInitialPassword = async (newPassword: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch<PasswordChangeResponse>('/api/auth/set-initial-password', {
|
||||
method: 'POST',
|
||||
body: { newPassword }
|
||||
})
|
||||
await syncPasswordChangeState(response, '初始密码设置成功,但刷新用户状态失败:')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async (redirect = true) => {
|
||||
try {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
} catch (error) {
|
||||
// 忽略登出错误
|
||||
}
|
||||
|
||||
clearAuthState()
|
||||
|
||||
// 清理 admin 相关全局状态
|
||||
try {
|
||||
const { resetUserFilters } = useUserFilters()
|
||||
resetUserFilters()
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (import.meta.client && redirect) {
|
||||
await navigateTo('/')
|
||||
}
|
||||
}
|
||||
|
||||
const getAuthConfig = () => {
|
||||
// 返回认证配置
|
||||
return {
|
||||
credentials: 'include' as RequestCredentials
|
||||
}
|
||||
}
|
||||
|
||||
const getToken = () => {
|
||||
// httpOnly cookie 模式下客户端不能读取真实 JWT,调用方应依赖同源 cookie。
|
||||
if (token.value === 'cookie-based') {
|
||||
return null
|
||||
}
|
||||
|
||||
return token.value
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
loading,
|
||||
login,
|
||||
verify2FA,
|
||||
logout,
|
||||
changePassword,
|
||||
setInitialPassword,
|
||||
refreshUser,
|
||||
initAuth,
|
||||
getToken,
|
||||
getAuthConfig
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { computed, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import type {
|
||||
AbstractBaseRenderer,
|
||||
BackgroundRender,
|
||||
MeshGradientRenderer
|
||||
} from '@applemusic-like-lyrics/core'
|
||||
|
||||
const isClient = typeof window !== 'undefined'
|
||||
let CoreModule: typeof import('@applemusic-like-lyrics/core') | null = null
|
||||
|
||||
const CORS_PROXY_PATH = '/api/proxy/image'
|
||||
|
||||
const needsCorsProxy = (url: string): boolean => {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const h = u.hostname.toLowerCase()
|
||||
return (
|
||||
h.endsWith('.y.qq.com') ||
|
||||
h === 'y.qq.com' ||
|
||||
h.endsWith('.y.gtimg.cn') ||
|
||||
h === 'y.gtimg.cn' ||
|
||||
h.endsWith('.music.126.net') ||
|
||||
h === 'music.126.net' ||
|
||||
h.endsWith('.musicapp.migu.cn') ||
|
||||
h === 'd.musicapp.migu.cn' ||
|
||||
h.endsWith('.migu.cn')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const toProxiedUrl = (url: string): string => {
|
||||
if (!needsCorsProxy(url)) return url
|
||||
return `${CORS_PROXY_PATH}?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
const ensureCoreModule = async () => {
|
||||
if (!isClient) return null
|
||||
|
||||
if (!CoreModule) {
|
||||
CoreModule = await import('@applemusic-like-lyrics/core')
|
||||
await import('@applemusic-like-lyrics/core/style.css')
|
||||
}
|
||||
|
||||
return CoreModule
|
||||
}
|
||||
|
||||
export interface BackgroundConfig {
|
||||
type: 'gradient' | 'cover'
|
||||
dynamic: boolean
|
||||
flowSpeed: number
|
||||
fps?: number
|
||||
renderScale?: number
|
||||
hasLyric?: boolean
|
||||
}
|
||||
|
||||
export const useBackgroundRenderer = () => {
|
||||
const backgroundRenderer = shallowRef<
|
||||
BackgroundRender<MeshGradientRenderer> | AbstractBaseRenderer | null
|
||||
>(null)
|
||||
const containerElement = ref<HTMLElement | null>(null)
|
||||
const coverBlurElement = ref<HTMLElement | null>(null)
|
||||
const isInitialized = ref(false)
|
||||
const isRendering = ref(false)
|
||||
const isMotionPaused = ref(false)
|
||||
const hasRenderError = ref(false)
|
||||
const currentCoverUrl = ref('')
|
||||
const loadedCoverUrl = ref('')
|
||||
|
||||
const config = ref<BackgroundConfig>({
|
||||
type: 'gradient',
|
||||
dynamic: true,
|
||||
flowSpeed: 2,
|
||||
fps: 30,
|
||||
renderScale: 0.5,
|
||||
hasLyric: true
|
||||
})
|
||||
|
||||
const applyCanvasStyle = (canvas: HTMLElement) => {
|
||||
canvas.style.position = 'absolute'
|
||||
canvas.style.inset = '0'
|
||||
canvas.style.width = '100%'
|
||||
canvas.style.height = '100%'
|
||||
canvas.style.display = 'block'
|
||||
canvas.style.zIndex = '0'
|
||||
canvas.style.pointerEvents = 'none'
|
||||
}
|
||||
|
||||
const syncRendererMotion = () => {
|
||||
const renderer = backgroundRenderer.value
|
||||
if (!renderer) return
|
||||
|
||||
if (!isRendering.value) {
|
||||
renderer.pause()
|
||||
return
|
||||
}
|
||||
|
||||
renderer.setStaticMode(false)
|
||||
|
||||
if (!config.value.dynamic) {
|
||||
renderer.setFlowSpeed(0)
|
||||
renderer.pause()
|
||||
return
|
||||
}
|
||||
|
||||
renderer.setFlowSpeed(isMotionPaused.value ? 0 : config.value.flowSpeed)
|
||||
renderer.resume()
|
||||
}
|
||||
|
||||
const applyRendererState = async () => {
|
||||
const renderer = backgroundRenderer.value
|
||||
if (!renderer) return
|
||||
|
||||
renderer.setFPS(config.value.fps ?? 30)
|
||||
renderer.setRenderScale(config.value.renderScale ?? 0.5)
|
||||
renderer.setHasLyric(config.value.hasLyric ?? true)
|
||||
|
||||
const cover = currentCoverUrl.value
|
||||
if (cover !== loadedCoverUrl.value) {
|
||||
try {
|
||||
await renderer.setAlbum(cover ? toProxiedUrl(cover) : '', false)
|
||||
if (currentCoverUrl.value === cover) {
|
||||
loadedCoverUrl.value = cover
|
||||
hasRenderError.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
if (currentCoverUrl.value === cover) {
|
||||
hasRenderError.value = true
|
||||
}
|
||||
console.error('应用封面到背景失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
syncRendererMotion()
|
||||
}
|
||||
|
||||
const initializeBackground = async (container: HTMLElement) => {
|
||||
if (!container) return
|
||||
if (isInitialized.value && containerElement.value === container) {
|
||||
await applyRendererState()
|
||||
return
|
||||
}
|
||||
|
||||
dispose()
|
||||
containerElement.value = container
|
||||
hasRenderError.value = false
|
||||
|
||||
try {
|
||||
const Core = await ensureCoreModule()
|
||||
if (!Core) return
|
||||
|
||||
const renderer = Core.BackgroundRender.new(Core.MeshGradientRenderer)
|
||||
backgroundRenderer.value = renderer
|
||||
|
||||
const canvas = renderer.getElement()
|
||||
applyCanvasStyle(canvas)
|
||||
container.appendChild(canvas)
|
||||
|
||||
isInitialized.value = true
|
||||
await applyRendererState()
|
||||
} catch (error) {
|
||||
hasRenderError.value = true
|
||||
console.error('背景渲染器初始化失败:', error)
|
||||
dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const updateConfig = async (newConfig: Partial<BackgroundConfig>) => {
|
||||
config.value = { ...config.value, ...newConfig }
|
||||
await applyRendererState()
|
||||
}
|
||||
|
||||
const setCoverBackground = async (coverUrl: string) => {
|
||||
currentCoverUrl.value = coverUrl || ''
|
||||
|
||||
if (coverBlurElement.value) {
|
||||
coverBlurElement.value.style.backgroundImage = coverUrl ? `url(${coverUrl})` : ''
|
||||
}
|
||||
|
||||
await applyRendererState()
|
||||
}
|
||||
|
||||
const setCoverBlurElement = (element: HTMLElement | null) => {
|
||||
coverBlurElement.value = element
|
||||
}
|
||||
|
||||
const setGradientFromCover = async (coverUrl: string) => {
|
||||
await setCoverBackground(coverUrl)
|
||||
}
|
||||
|
||||
const startRender = () => {
|
||||
isRendering.value = true
|
||||
isMotionPaused.value = false
|
||||
syncRendererMotion()
|
||||
}
|
||||
|
||||
const stopRender = () => {
|
||||
const renderer = backgroundRenderer.value
|
||||
if (!renderer) return
|
||||
|
||||
isRendering.value = false
|
||||
isMotionPaused.value = false
|
||||
renderer.setFlowSpeed(0)
|
||||
renderer.pause()
|
||||
}
|
||||
|
||||
const pauseRender = () => {
|
||||
isMotionPaused.value = true
|
||||
if (!backgroundRenderer.value) return
|
||||
syncRendererMotion()
|
||||
}
|
||||
|
||||
const resumeRender = () => {
|
||||
isMotionPaused.value = false
|
||||
if (!backgroundRenderer.value) return
|
||||
syncRendererMotion()
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
const renderer = backgroundRenderer.value
|
||||
|
||||
if (renderer) {
|
||||
try {
|
||||
renderer.pause()
|
||||
renderer.dispose()
|
||||
} catch (error) {
|
||||
console.error('清理背景渲染器失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
backgroundRenderer.value = null
|
||||
isInitialized.value = false
|
||||
isRendering.value = false
|
||||
isMotionPaused.value = false
|
||||
containerElement.value = null
|
||||
coverBlurElement.value = null
|
||||
currentCoverUrl.value = ''
|
||||
loadedCoverUrl.value = ''
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
backgroundRenderer: computed(() => backgroundRenderer.value),
|
||||
isInitialized: computed(() => isInitialized.value),
|
||||
isRendering: computed(() => isRendering.value),
|
||||
hasRenderError: computed(() => hasRenderError.value),
|
||||
config: computed(() => config.value),
|
||||
initializeBackground,
|
||||
updateConfig,
|
||||
setCoverBackground,
|
||||
setCoverBlurElement,
|
||||
setGradientFromCover,
|
||||
startRender,
|
||||
stopRender,
|
||||
pauseRender,
|
||||
resumeRender,
|
||||
dispose,
|
||||
applyConfig: applyRendererState
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 用于管理哔哩哔哩视频预览弹窗的组合式函数
|
||||
* 提取了 SongManagement 和 ScheduleManager 组件中的通用逻辑
|
||||
*/
|
||||
export function useBilibiliPreview() {
|
||||
const showBilibiliPreview = ref(false)
|
||||
const previewBvid = ref('')
|
||||
const previewPage = ref(1)
|
||||
|
||||
const openBilibiliPreview = (song: any) => {
|
||||
if (!song.musicId) return
|
||||
|
||||
const parts = song.musicId.split(':')
|
||||
previewBvid.value = parts[0]
|
||||
previewPage.value = parts.length >= 3 ? parseInt(parts[2]) || 1 : 1
|
||||
showBilibiliPreview.value = true
|
||||
}
|
||||
|
||||
const closeBilibiliPreview = () => {
|
||||
showBilibiliPreview.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
showBilibiliPreview,
|
||||
previewBvid,
|
||||
previewPage,
|
||||
openBilibiliPreview,
|
||||
closeBilibiliPreview
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// ChKSz 音源(api.chksz.com):用户自配 apikey,前端直连(接口返回 Access-Control-Allow-Origin: *)
|
||||
// 仅支持 netease / tencent 平台,接口免费配额有限(约 400 次 + 每分钟 20 次),需缓存与限流
|
||||
const CHKSZ_BASE_URL = 'https://api.chksz.com'
|
||||
const STORAGE_KEY = 'chksz_api_key'
|
||||
|
||||
// 播放链接缓存(5 分钟,chksz 返回的直链有时效)
|
||||
const CHKSZ_CACHE_TTL = 5 * 60 * 1000
|
||||
// 滑动窗口限流:60 秒内最多 18 次,留余量规避接口 20 次/分钟限制
|
||||
const CHKSZ_RATE_LIMIT_MAX = 18
|
||||
const CHKSZ_RATE_LIMIT_WINDOW = 60 * 1000
|
||||
|
||||
// 项目音质值 → chksz 接口参数
|
||||
// 网易 level:standard/exhigh/lossless/hires/jymaster
|
||||
const NETEASE_LEVEL_MAP: Record<number, string> = {
|
||||
2: 'standard',
|
||||
4: 'exhigh',
|
||||
5: 'lossless',
|
||||
6: 'hires',
|
||||
9: 'jymaster'
|
||||
}
|
||||
// QQ size:128k/320k/flac/hires/master
|
||||
const TENCENT_SIZE_MAP: Record<number, string> = {
|
||||
4: '128k',
|
||||
8: '320k',
|
||||
10: 'flac',
|
||||
11: 'hires',
|
||||
14: 'master'
|
||||
}
|
||||
|
||||
const chkszUrlCache = new Map<string, { url: string; expireAt: number }>()
|
||||
// 同 key 请求合并,避免并发重复请求
|
||||
const chkszInflight = new Map<string, Promise<string | null>>()
|
||||
// 最近请求时间戳(滑动窗口限流)
|
||||
const chkszRequestTimes: number[] = []
|
||||
|
||||
const readStoredKey = (): string => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
let globalChkszKey: any = null
|
||||
|
||||
export function useChkszSource() {
|
||||
if (!globalChkszKey) {
|
||||
globalChkszKey = ref(readStoredKey())
|
||||
|
||||
if (import.meta.client) {
|
||||
watch(
|
||||
globalChkszKey,
|
||||
(newValue) => {
|
||||
try {
|
||||
if (newValue && newValue.trim()) {
|
||||
localStorage.setItem(STORAGE_KEY, newValue.trim())
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[chksz] Failed to save apikey:', error)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const chkszApiKey = globalChkszKey
|
||||
|
||||
const hasChkszKey = () => {
|
||||
const key = chkszApiKey.value
|
||||
return !!(key && key.trim())
|
||||
}
|
||||
|
||||
const clearChkszKey = () => {
|
||||
chkszApiKey.value = ''
|
||||
}
|
||||
|
||||
const checkRateLimit = (): boolean => {
|
||||
const now = Date.now()
|
||||
const windowStart = now - CHKSZ_RATE_LIMIT_WINDOW
|
||||
while (chkszRequestTimes.length > 0 && chkszRequestTimes[0] < windowStart) {
|
||||
chkszRequestTimes.shift()
|
||||
}
|
||||
if (chkszRequestTimes.length >= CHKSZ_RATE_LIMIT_MAX) {
|
||||
return false
|
||||
}
|
||||
chkszRequestTimes.push(now)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ChKSz 获取播放链接
|
||||
* @returns 播放 URL;未配 key / 不支持的平台音质 / 接口失败均返回 null(走降级链)
|
||||
*/
|
||||
const getChkszMusicUrl = async (
|
||||
platform: string,
|
||||
musicId: string | number,
|
||||
quality: number | string
|
||||
): Promise<string | null> => {
|
||||
if (!hasChkszKey()) return null
|
||||
|
||||
const apiKey = chkszApiKey.value.trim()
|
||||
const normalizedQuality = Number(quality)
|
||||
let endpoint = ''
|
||||
let qualityParam = ''
|
||||
|
||||
if (platform === 'netease') {
|
||||
const level = NETEASE_LEVEL_MAP[normalizedQuality]
|
||||
if (!level) return null
|
||||
endpoint = '163_music'
|
||||
qualityParam = `level=${level}`
|
||||
} else if (platform === 'tencent') {
|
||||
const size = TENCENT_SIZE_MAP[normalizedQuality]
|
||||
if (!size) return null
|
||||
endpoint = 'qq_music'
|
||||
qualityParam = `size=${size}&type=json`
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheKey = `${platform}:${musicId}:${qualityParam}`
|
||||
const cached = chkszUrlCache.get(cacheKey)
|
||||
if (cached && cached.expireAt > Date.now()) {
|
||||
return cached.url
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of chkszUrlCache) {
|
||||
if (entry.expireAt <= now) {
|
||||
chkszUrlCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const inflight = chkszInflight.get(cacheKey)
|
||||
if (inflight) {
|
||||
return inflight
|
||||
}
|
||||
|
||||
if (!checkRateLimit()) {
|
||||
console.warn('[chksz] 请求过于频繁,跳过本次请求')
|
||||
return null
|
||||
}
|
||||
|
||||
const promise = (async (): Promise<string | null> => {
|
||||
try {
|
||||
const apiUrl =
|
||||
`${CHKSZ_BASE_URL}/api/${endpoint}?` +
|
||||
`${platform === 'netease' ? 'id' : 'mid'}=${encodeURIComponent(String(musicId))}&` +
|
||||
`${qualityParam}&apikey=${encodeURIComponent(apiKey)}`
|
||||
const response = await fetch(apiUrl, { signal: AbortSignal.timeout(8000) })
|
||||
if (!response.ok) return null
|
||||
const data: any = await response.json()
|
||||
const url = platform === 'netease' ? data?.data?.url : data?.url
|
||||
if (data?.code === 200 && url && /^https?:\/\//i.test(String(url))) {
|
||||
const finalUrl = String(url)
|
||||
chkszUrlCache.set(cacheKey, { url: finalUrl, expireAt: Date.now() + CHKSZ_CACHE_TTL })
|
||||
return finalUrl
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[chksz] 获取播放链接失败:', error)
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
chkszInflight.set(cacheKey, promise)
|
||||
promise.finally(() => {
|
||||
chkszInflight.delete(cacheKey)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 apikey 有效性(消耗 1 次接口配额)
|
||||
*/
|
||||
const verifyChkszKey = async (
|
||||
key?: string
|
||||
): Promise<{ valid: boolean; message: string; detail?: string }> => {
|
||||
const apiKey = (key !== undefined ? key : chkszApiKey.value).trim()
|
||||
if (!apiKey) {
|
||||
return { valid: false, message: 'empty' }
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${CHKSZ_BASE_URL}/api/163_music?id=347230&level=standard&apikey=${encodeURIComponent(apiKey)}`,
|
||||
{ signal: AbortSignal.timeout(8000) }
|
||||
)
|
||||
if (!response.ok) {
|
||||
return { valid: false, message: `http_${response.status}` }
|
||||
}
|
||||
const data: any = await response.json()
|
||||
if (data?.code === 200 && data?.data?.url) {
|
||||
return { valid: true, message: 'ok' }
|
||||
}
|
||||
// key 无效(401/403 语义)或配额耗尽时接口返回业务错误码
|
||||
return { valid: false, message: 'api_error', detail: data?.msg }
|
||||
} catch (error: any) {
|
||||
return { valid: false, message: error?.name === 'TimeoutError' ? 'timeout' : 'network' }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chkszApiKey,
|
||||
hasChkszKey,
|
||||
clearChkszKey,
|
||||
getChkszMusicUrl,
|
||||
verifyChkszKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
// 防抖机制
|
||||
let isHandling401 = false
|
||||
const lastHandle401Time = 0
|
||||
const HANDLE_401_DEBOUNCE_TIME = 2000
|
||||
|
||||
export const useErrorHandler = () => {
|
||||
// 处理401认证失效错误
|
||||
const handle401Error = async (error: any) => {
|
||||
// 防抖机制
|
||||
if (isHandling401) {
|
||||
return
|
||||
}
|
||||
|
||||
isHandling401 = true
|
||||
|
||||
try {
|
||||
const currentPath = import.meta.client ? window.location.pathname : ''
|
||||
|
||||
// 检查是否在登录页面
|
||||
const isLoginPage = currentPath === '/login'
|
||||
|
||||
if (isLoginPage) {
|
||||
// 在登录页面,解析错误信息
|
||||
let errorMessage = '登录失败'
|
||||
|
||||
if (error?.data?.message) {
|
||||
errorMessage = error.data.message
|
||||
} else if (error?.message) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
console.log('登录失败:', errorMessage)
|
||||
|
||||
throw error
|
||||
} else {
|
||||
// 不在登录页面,清除认证状态并跳转到登录页
|
||||
|
||||
// 检查是否已认证
|
||||
const auth = useAuth()
|
||||
const wasAuthenticated = auth.isAuthenticated.value
|
||||
|
||||
// 如果之前未认证(如首次访问),则不显示错误提示
|
||||
if (!wasAuthenticated) {
|
||||
console.log('未认证状态下收到401,忽略提示')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('检测到401错误,清除认证状态并跳转到登录页')
|
||||
|
||||
let errorMessage = '认证失败,请重新登录'
|
||||
|
||||
if (error?.data?.message) {
|
||||
errorMessage = error.data.message
|
||||
} else if (error?.message) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
console.log('认证错误:', errorMessage)
|
||||
|
||||
// 清除认证状态
|
||||
auth.user.value = null
|
||||
auth.token.value = null
|
||||
auth.isAuthenticated.value = false
|
||||
auth.isAdmin.value = false
|
||||
|
||||
// 清除 cookie
|
||||
if (import.meta.client) {
|
||||
// 删除 auth-token cookie
|
||||
document.cookie = 'auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'
|
||||
|
||||
// 如果是 HTTPS,也尝试删除 secure cookie
|
||||
if (window.location.protocol === 'https:') {
|
||||
document.cookie = 'auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; Secure'
|
||||
}
|
||||
}
|
||||
|
||||
// 显示错误提示
|
||||
if (import.meta.client) {
|
||||
const toast = useToast()
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
|
||||
// 跳转到登录页
|
||||
if (import.meta.client) {
|
||||
await navigateTo('/login')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 延迟重置防抖标志
|
||||
setTimeout(() => {
|
||||
isHandling401 = false
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查响应是否为401错误并处理
|
||||
const checkAndHandle401 = async (response: Response, error?: any) => {
|
||||
if (response.status === 401) {
|
||||
await handle401Error(error)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查useFetch错误是否为401并处理
|
||||
const checkAndHandleFetchError = async (error: any) => {
|
||||
if (error?.status === 401 || error?.statusCode === 401) {
|
||||
await handle401Error(error)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
handle401Error,
|
||||
checkAndHandle401,
|
||||
checkAndHandleFetchError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from '#app'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useServerErrors } from '~/composables/useLocaleText'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
export interface ImportantNotification {
|
||||
id: number
|
||||
title: string | null
|
||||
message: string
|
||||
important: boolean
|
||||
read: boolean
|
||||
createdAt: string
|
||||
sender: {
|
||||
id: number
|
||||
name: string | null
|
||||
username: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
let inFlightCheck: Promise<ImportantNotification | null> | null = null
|
||||
let inFlightUserId: number | null = null
|
||||
let requestVersion = 0
|
||||
|
||||
export const useImportantNotification = () => {
|
||||
const notification = useState<ImportantNotification | null>('important-notification', () => null)
|
||||
const loading = useState<boolean>('important-notification-loading', () => false)
|
||||
const closing = useState<boolean>('important-notification-closing', () => false)
|
||||
const error = useState<string>('important-notification-error', () => '')
|
||||
const checkedUserId = useState<number | null>('important-notification-user', () => null)
|
||||
const { user, isAuthenticated } = useAuth()
|
||||
const { localize: localizeServerError } = useServerErrors()
|
||||
const { importantNotification: locale } = useLocale()
|
||||
const toast = useToast()
|
||||
|
||||
const resetImportantNotification = () => {
|
||||
requestVersion += 1
|
||||
notification.value = null
|
||||
loading.value = false
|
||||
closing.value = false
|
||||
error.value = ''
|
||||
checkedUserId.value = null
|
||||
inFlightCheck = null
|
||||
inFlightUserId = null
|
||||
}
|
||||
|
||||
const checkImportantNotification = async (force = false) => {
|
||||
if (import.meta.server) return null
|
||||
|
||||
const currentUserId = user.value?.id
|
||||
if (!isAuthenticated.value || !currentUserId || user.value?.requirePasswordChange === true) {
|
||||
resetImportantNotification()
|
||||
return null
|
||||
}
|
||||
|
||||
if (!force && checkedUserId.value === currentUserId) {
|
||||
return notification.value
|
||||
}
|
||||
|
||||
if (inFlightCheck && inFlightUserId === currentUserId) {
|
||||
return inFlightCheck
|
||||
}
|
||||
|
||||
const activeRequestVersion = requestVersion
|
||||
inFlightUserId = currentUserId
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
const checkPromise = $fetch<{ notification: ImportantNotification | null }>(
|
||||
'/api/notifications/important'
|
||||
)
|
||||
.then((response) => {
|
||||
if (
|
||||
activeRequestVersion !== requestVersion ||
|
||||
!isAuthenticated.value ||
|
||||
user.value?.id !== currentUserId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
notification.value = response.notification
|
||||
checkedUserId.value = currentUserId
|
||||
return response.notification
|
||||
})
|
||||
.catch((fetchError) => {
|
||||
if (activeRequestVersion !== requestVersion) return null
|
||||
|
||||
error.value = localizeServerError(fetchError, locale.value.loadFailed)
|
||||
toast.error(error.value)
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeRequestVersion === requestVersion) {
|
||||
loading.value = false
|
||||
}
|
||||
if (inFlightCheck === checkPromise) {
|
||||
inFlightCheck = null
|
||||
inFlightUserId = null
|
||||
}
|
||||
})
|
||||
|
||||
inFlightCheck = checkPromise
|
||||
return checkPromise
|
||||
}
|
||||
|
||||
const markAsReadAndClose = async () => {
|
||||
const currentNotification = notification.value
|
||||
const currentUserId = user.value?.id
|
||||
if (!currentNotification || !currentUserId || closing.value) return false
|
||||
|
||||
closing.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/api/notifications/${currentNotification.id}/read`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (user.value?.id !== currentUserId) {
|
||||
resetImportantNotification()
|
||||
return true
|
||||
}
|
||||
|
||||
if (notification.value?.id === currentNotification.id) {
|
||||
notification.value = { ...notification.value, read: true }
|
||||
notification.value = null
|
||||
}
|
||||
checkedUserId.value = null
|
||||
await checkImportantNotification(true)
|
||||
return true
|
||||
} catch (fetchError) {
|
||||
// 通知已被管理员编辑重发或删除时旧 ID 会 404,按已不存在处理,避免弹窗无法关闭
|
||||
const notFoundError = fetchError as { statusCode?: number; response?: { status?: number } }
|
||||
if (notFoundError?.statusCode === 404 || notFoundError?.response?.status === 404) {
|
||||
if (notification.value?.id === currentNotification.id) {
|
||||
notification.value = null
|
||||
}
|
||||
checkedUserId.value = null
|
||||
await checkImportantNotification(true)
|
||||
return true
|
||||
}
|
||||
error.value = localizeServerError(fetchError, locale.value.closeFailed)
|
||||
return false
|
||||
} finally {
|
||||
closing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
notification,
|
||||
loading,
|
||||
closing,
|
||||
error,
|
||||
checkedUserId,
|
||||
checkImportantNotification,
|
||||
markAsReadAndClose,
|
||||
resetImportantNotification
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
/**
|
||||
* Shared locale-access helpers.
|
||||
*
|
||||
* Historically ~30 components each re-implemented their own accessor
|
||||
* (`callLocale`, `getLocaleMessage`, `getNestedMessage`, `formatLocaleValue`,
|
||||
* `getThrownMessage`, `formatString`, ...). These are the single source of truth.
|
||||
*
|
||||
* Two families:
|
||||
* - Standalone helpers (`formatLocaleValue`, `getThrownMessage`) that operate on an
|
||||
* already-resolved value / error object and need no locale source.
|
||||
* - `useLocaleText(source)` which binds a locale section (raw object or a
|
||||
* `useSafeLocale` proxy) and exposes key/path lookups (`t`, `msg`, `nested`)
|
||||
* plus the `format` / `errMessage` conveniences.
|
||||
*
|
||||
* Substitution supports `{0}`, `{1}`, ... positional placeholders. `format`/`t`
|
||||
* additionally accept the `{count}` alias (→ arg 0) used by a couple of pluralized
|
||||
* strings.
|
||||
*/
|
||||
|
||||
/** Replace `{0}`, `{1}`, ... plus the `{count}` alias (→ arg 0) in a template string. */
|
||||
const substituteWithCount = (template: string, args: unknown[]): string =>
|
||||
template.replace(/{(\d+)}|{count}/g, (match, index) => {
|
||||
const argIndex = match === '{count}' ? 0 : Number(index)
|
||||
// 缺参时回退为空串,避免把原始占位符(如 {0})暴露给用户
|
||||
return args[argIndex] !== undefined ? String(args[argIndex]) : ''
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve an already-obtained locale value to a display string.
|
||||
* - function → call it with `args` (locale formatter)
|
||||
* - string → positional `{n}` substitution
|
||||
* - otherwise → the value itself, or `''`
|
||||
*
|
||||
* Matches the dominant `formatLocaleValue(value, ...args)` variant used across the app.
|
||||
*/
|
||||
export const formatLocaleValue = (value: unknown, ...args: unknown[]): string => {
|
||||
if (typeof value === 'function') return (value as (...a: unknown[]) => unknown)(...args) as string
|
||||
if (typeof value === 'string') return substituteWithCount(value, args)
|
||||
return (value as string) ?? ''
|
||||
}
|
||||
|
||||
/** Alias kept for call sites that used the `formatLocale` name (identical semantics). */
|
||||
export const formatLocale = formatLocaleValue
|
||||
|
||||
/**
|
||||
* ofetch 抛出的 FetchError.message 形如 `[POST] "/api/x": 401 ...`、`[GET] "...": <no response>`,
|
||||
* 属于面向开发者的技术串,绝不能作为用户可见文案;识别后跳过,交由上层本地化兜底处理。
|
||||
*/
|
||||
const isFetchEnvelopeMessage = (message: string): boolean =>
|
||||
/^\[[A-Za-z]+\]\s+".*":\s+/.test(message)
|
||||
|
||||
/**
|
||||
* Extract a human-readable message from a thrown error / failed `$fetch` response.
|
||||
* Superset of the previous `getThrownMessage` / `getErrorMessage` variants.
|
||||
* 会过滤掉 ofetch 的技术性包装串,避免其泄漏到用户界面。
|
||||
*/
|
||||
export const getThrownMessage = (err: any): string => {
|
||||
if (!err) return ''
|
||||
if (typeof err === 'string') return err
|
||||
const candidates = [
|
||||
err?.data?.message,
|
||||
err?.data?.statusMessage,
|
||||
err?.message,
|
||||
err?.statusMessage
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate && !isFetchEnvelopeMessage(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Alias kept for call sites that used the `getErrorMessage` name. */
|
||||
export const getErrorMessage = getThrownMessage
|
||||
|
||||
export const useLocaleText = (
|
||||
source: MaybeRefOrGetter<Record<string, any> | null | undefined>
|
||||
) => {
|
||||
/** Resolve a value at `path` ("a" or "a.b.c") within the bound locale source. */
|
||||
const lookup = (path: string): unknown => {
|
||||
const base = toValue(source)
|
||||
return String(path)
|
||||
.split('.')
|
||||
.reduce<any>((target, key) => (target == null ? undefined : target[key]), base)
|
||||
}
|
||||
|
||||
/**
|
||||
* key/path → localized string with `{n}`/`{count}` substitution and a fallback.
|
||||
* Replaces the various `callLocale(key, fallback, ...args)` implementations.
|
||||
*/
|
||||
const t = (path: string, fallback = '', ...args: unknown[]): string => {
|
||||
const value = lookup(path)
|
||||
if (typeof value === 'function') return (value as (...a: unknown[]) => unknown)(...args) as string || fallback
|
||||
if (typeof value === 'string') return substituteWithCount(value, args) || fallback
|
||||
return (value as string) || fallback
|
||||
}
|
||||
|
||||
/** flat key → localized string, no fallback. Replaces `getLocaleMessage(key, ...args)`. */
|
||||
const msg = (key: string, ...args: unknown[]): string => t(key, '', ...args)
|
||||
|
||||
/** two-level section+key → localized string. Replaces `getNestedMessage(section, key, ...args)`. */
|
||||
const nested = (section: string, key: string, ...args: unknown[]): string =>
|
||||
t(`${section}.${key}`, '', ...args)
|
||||
|
||||
/**
|
||||
* Resolved value → localized string with a fallback and `{count}` support.
|
||||
* Replaces `formatLocaleValue(value, fallback, ...args)` (the fallback-bearing variant).
|
||||
*/
|
||||
const format = (value: unknown, fallback = '', ...args: unknown[]): string => {
|
||||
if (typeof value === 'function') return (value as (...a: unknown[]) => unknown)(...args) as string || fallback
|
||||
if (typeof value === 'string') return substituteWithCount(value, args) || fallback
|
||||
return (value as string) || fallback
|
||||
}
|
||||
|
||||
return { t, msg, nested, format, errMessage: getThrownMessage, lookup }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the stable machine-readable error code from a thrown error / failed `$fetch`.
|
||||
* Errors created via `server/utils/apiError.ts#createApiError` carry the code in both
|
||||
* `statusMessage` and `data.code`; Nuxt/`$fetch` nests the server payload under `err.data`,
|
||||
* so we probe both depths. Mirrors the ad-hoc `getCardCodeErrorCode` this replaces.
|
||||
*/
|
||||
export const extractErrorCode = (err: any): string => {
|
||||
if (!err) return ''
|
||||
return (
|
||||
err?.data?.data?.code ||
|
||||
err?.data?.code ||
|
||||
err?.data?.statusMessage ||
|
||||
err?.statusMessage ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
/** Positional substitution params optionally carried on the error payload (`data.params`). */
|
||||
const extractErrorParams = (err: any): unknown[] => {
|
||||
const params = err?.data?.data?.params ?? err?.data?.params
|
||||
return Array.isArray(params) ? params : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Code-aware localization for server-thrown errors.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `serverErrors[code]` dictionary entry in the current locale (with `{n}` substitution
|
||||
* from `err.data.params`), falling back to zh-CN via the dictionary merge;
|
||||
* 2. the server-provided `message` (简体中文默认文案, from `createApiError`), 已过滤 ofetch 技术包装串;
|
||||
* 3. the supplied `fallback` string;
|
||||
* 4. 本地化通用兜底文案(按当前语言)。
|
||||
*
|
||||
* This is the single client entry point that decouples server messages from a specific
|
||||
* language, superseding the hand-maintained mirror maps (card-code tables, `serverMessages`).
|
||||
*/
|
||||
export const useServerErrors = () => {
|
||||
const { serverErrors, currentLocale } = useLocale()
|
||||
|
||||
const localize = (err: any, fallback = ''): string => {
|
||||
const code = extractErrorCode(err)
|
||||
if (code) {
|
||||
const entry = (serverErrors.value as Record<string, unknown> | undefined)?.[code]
|
||||
if (entry !== undefined && entry !== null) {
|
||||
return formatLocaleValue(entry, ...extractErrorParams(err))
|
||||
}
|
||||
}
|
||||
const thrown = getThrownMessage(err)
|
||||
if (thrown) return thrown
|
||||
if (fallback) return fallback
|
||||
// catch-all:码未命中、无干净消息、调用方也未给兜底时,返回本地化通用文案,
|
||||
// 避免向用户展示空白或 ofetch 原始技术串。
|
||||
return currentLocale.value === 'en-US'
|
||||
? 'Request failed, please try again later.'
|
||||
: '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
return { localize, extractErrorCode }
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { computed, effectScope, ref, watch, type EffectScope } from 'vue'
|
||||
import { useAudioPlayer } from './useAudioPlayer'
|
||||
import { useMusicSources } from './useMusicSources'
|
||||
import { useLyricSettings } from './useLyricSettings'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import {
|
||||
parseSmartLrc,
|
||||
alignLyrics,
|
||||
cleanTTMLTranslations,
|
||||
parseQRCLyric,
|
||||
type LrcFormat
|
||||
} from '~/utils/lyric/lyricParser'
|
||||
import { formatLyric } from '~/utils/lyric/lyricFormat'
|
||||
import type { LyricLine } from '@applemusic-like-lyrics/lyric'
|
||||
import { parseTTML, parseYrc } from '@applemusic-like-lyrics/lyric'
|
||||
|
||||
/** 模块级共享状态:AMLyric 与 DefaultLyric 各自调用 useLyricManager 时共享同一份数据 */
|
||||
const sharedLyrics = ref<LyricLine[]>([])
|
||||
const sharedLoading = ref(false)
|
||||
const sharedError = ref<string | null>(null)
|
||||
const sharedCurrentTrackId = ref<string | null>(null)
|
||||
const sharedHasTranslation = ref(false)
|
||||
const sharedHasRoma = ref(false)
|
||||
const sharedLyricFormat = ref<LrcFormat | 'ttml' | 'qrc'>('line')
|
||||
/** 当前歌词实际使用的来源(用于界面展示与问题定位) */
|
||||
const sharedLyricSource = ref<string>('')
|
||||
|
||||
// 竞态令牌:每次新请求递增,旧请求收到结果后与当前令牌比对,不一致则丢弃
|
||||
let currentToken = 0
|
||||
|
||||
// 全局 watch 只注册一次(detached scope 脱离组件生命周期,避免组件卸载后停止同步)
|
||||
let managerScope: EffectScope | null = null
|
||||
|
||||
export const useLyricManager = () => {
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const { composableErrors } = useLocale()
|
||||
const lyricMessages = computed(() => composableErrors.value.lyrics)
|
||||
const { getLyrics } = useMusicSources()
|
||||
const settings = useLyricSettings()
|
||||
|
||||
const lyrics = sharedLyrics
|
||||
const loading = sharedLoading
|
||||
const error = sharedError
|
||||
const currentTrackId = sharedCurrentTrackId
|
||||
const hasTranslation = sharedHasTranslation
|
||||
const hasRoma = sharedHasRoma
|
||||
const lyricFormat = sharedLyricFormat
|
||||
const lyricSource = sharedLyricSource
|
||||
|
||||
const clearLyrics = () => {
|
||||
lyrics.value = []
|
||||
error.value = null
|
||||
hasTranslation.value = false
|
||||
hasRoma.value = false
|
||||
currentTrackId.value = null
|
||||
lyricFormat.value = 'line'
|
||||
lyricSource.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断歌词实际来源:
|
||||
* ttml → AMLL DB;逐字 QRC → QQ;其余按抓取阶段与平台映射
|
||||
*/
|
||||
const inferLyricSource = (
|
||||
format: LrcFormat | 'ttml' | 'qrc',
|
||||
stage: string,
|
||||
track: any
|
||||
): string => {
|
||||
const platform = track?.musicPlatform || 'netease'
|
||||
if (format === 'ttml') return 'amll'
|
||||
if (format === 'qrc') return 'qm'
|
||||
if (stage === 'upgrade') {
|
||||
// 跨平台升级:数据来自对侧平台
|
||||
return platform === 'netease' ? 'qm' : 'official'
|
||||
}
|
||||
if (stage === 'meting') return 'meting'
|
||||
if (stage === 'amll') return 'amll'
|
||||
if (stage === 'qm') {
|
||||
// netease 平台的 qm 阶段走 vkeys 第三方接口
|
||||
return platform === 'netease' ? 'vkeys' : 'qm'
|
||||
}
|
||||
// official 阶段 / 最终结果按平台保守推断
|
||||
return platform === 'tencent' ? 'qm' : 'official'
|
||||
}
|
||||
|
||||
const applyLyricData = (
|
||||
track: any,
|
||||
lyricData: {
|
||||
lrc?: string
|
||||
trans?: string
|
||||
yrc?: string
|
||||
ttml?: string
|
||||
ytrans?: string
|
||||
} | null | undefined,
|
||||
sourceLabel: string,
|
||||
stage: string
|
||||
) => {
|
||||
if (!lyricData) return false
|
||||
|
||||
const { lrc, trans, yrc, ttml, ytrans } = lyricData
|
||||
console.log(
|
||||
`[LyricManager] ${sourceLabel} 获取结果: LRC=${!!lrc}, TRANS=${!!trans}, YRC=${!!yrc}, TTML=${!!ttml}, YTRANS=${!!ytrans}`
|
||||
)
|
||||
|
||||
let parsedLyrics: LyricLine[] = []
|
||||
let format: LrcFormat | 'ttml' | 'qrc' = 'line'
|
||||
|
||||
// 优先级:TTML > YRC/QRC > LRC
|
||||
if (settings.enableOnlineTTMLLyric.value && ttml) {
|
||||
try {
|
||||
const cleaned = cleanTTMLTranslations(ttml)
|
||||
const parsed = parseTTML(cleaned)
|
||||
const lines = (parsed as any)?.lines ?? []
|
||||
if (lines.length > 0) {
|
||||
parsedLyrics = lines
|
||||
format = 'ttml'
|
||||
console.log('[LyricManager] 使用 TTML 格式')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[LyricManager] TTML 解析失败,回退', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedLyrics.length === 0 && yrc) {
|
||||
// QRC:QQ音乐 XML 格式
|
||||
if (yrc.trim().startsWith('<') || yrc.includes('LyricContent="')) {
|
||||
try {
|
||||
const qrcLines = parseQRCLyric(yrc, trans, undefined)
|
||||
if (qrcLines.length > 0) {
|
||||
parsedLyrics = qrcLines
|
||||
format = 'qrc'
|
||||
console.log('[LyricManager] 使用 QRC 格式')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[LyricManager] QRC 解析失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// YRC:网易云 JSON 逐字格式
|
||||
if (parsedLyrics.length === 0) {
|
||||
try {
|
||||
const lines = parseYrc(yrc)
|
||||
if (lines && lines.length > 0) {
|
||||
const validLines = lines.filter((l) => l.words && l.words.length > 0)
|
||||
if (validLines.length > 0) {
|
||||
parsedLyrics = lines
|
||||
format = 'word-by-word'
|
||||
console.log('[LyricManager] 使用 YRC 格式')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// parseYrc 失败时回退 parseSmartLrc
|
||||
}
|
||||
|
||||
// parseYrc 无效,用 SmartLrc 兜底
|
||||
if (parsedLyrics.length === 0) {
|
||||
const { lines, format: detectedFormat } = parseSmartLrc(yrc)
|
||||
if (lines.length > 0) {
|
||||
parsedLyrics = lines
|
||||
format = detectedFormat
|
||||
console.log(`[LyricManager] 使用 YRC→SmartLrc 格式 (${detectedFormat})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedLyrics.length === 0 && lrc) {
|
||||
const { lines, format: detectedFormat } = parseSmartLrc(lrc)
|
||||
parsedLyrics = lines
|
||||
format = detectedFormat
|
||||
console.log(`[LyricManager] 使用 LRC 格式 (${detectedFormat})`)
|
||||
}
|
||||
|
||||
if (parsedLyrics.length === 0) return false
|
||||
|
||||
hasTranslation.value = false
|
||||
hasRoma.value = false
|
||||
|
||||
// 对齐翻译(TTML 已内嵌翻译,不需要再 align)
|
||||
if (format !== 'ttml') {
|
||||
// 逐字歌词(YRC/QRC)用 ytlrc(YRC 对齐翻译)优先;普通 LRC 用 tlyric
|
||||
const isWordLevel = format === 'qrc' || format === 'word-by-word'
|
||||
const transText = isWordLevel ? ytrans || trans : trans
|
||||
if (transText) {
|
||||
const { lines: transLines } = parseSmartLrc(transText)
|
||||
if (transLines.length > 0) {
|
||||
parsedLyrics = alignLyrics(parsedLyrics, transLines, 'translatedLyric')
|
||||
hasTranslation.value = true
|
||||
const transKind = ytrans && isWordLevel ? 'ytlrc' : 'tlyric'
|
||||
console.log(`[LyricManager] 已对齐翻译 (来源: ${transKind})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
title: track.title,
|
||||
artists: track.artist ? [track.artist] : undefined
|
||||
}
|
||||
|
||||
lyrics.value = formatLyric(parsedLyrics, settings, metadata)
|
||||
lyricFormat.value = format
|
||||
// 推断实际歌词来源(用于来源徽章展示与问题定位)
|
||||
lyricSource.value = inferLyricSource(format, stage, track)
|
||||
error.value = null
|
||||
console.log(`[LyricManager] 解析完成,格式=${format},行数=${lyrics.value.length}`)
|
||||
return true
|
||||
}
|
||||
|
||||
const fetchLyric = async (track: any, options?: { force?: boolean }) => {
|
||||
if (!track?.id) return
|
||||
|
||||
const trackId = track.id.toString()
|
||||
|
||||
// 已有歌词且非新歌曲,直接复用(force 用于切换歌词来源后强制重拉)
|
||||
if (!options?.force && currentTrackId.value === trackId && lyrics.value.length > 0) return
|
||||
|
||||
// 开启新令牌,使所有进行中的旧请求失效
|
||||
const token = ++currentToken
|
||||
currentTrackId.value = trackId
|
||||
loading.value = true
|
||||
error.value = null
|
||||
hasTranslation.value = false
|
||||
hasRoma.value = false
|
||||
lyricFormat.value = 'line'
|
||||
lyricSource.value = ''
|
||||
let hasRenderedProgress = false
|
||||
|
||||
const priority = settings.lyricPriority.value
|
||||
|
||||
try {
|
||||
const platform = track.musicPlatform || 'netease'
|
||||
const musicId = track.musicId || track.id
|
||||
|
||||
console.log(`[LyricManager] 开始获取歌词: ${track.title} (${platform}:${musicId})`)
|
||||
|
||||
const result = await getLyrics(platform, musicId, {
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: track.duration,
|
||||
priority,
|
||||
onProgress: ({ data, stage }) => {
|
||||
if (token !== currentToken) return
|
||||
const applied = applyLyricData(track, data, `阶段 ${stage}`, stage)
|
||||
if (applied) {
|
||||
hasRenderedProgress = true
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 令牌失效:歌曲已切换,丢弃结果
|
||||
if (token !== currentToken) return
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.error || lyricMessages.value.notFound)
|
||||
}
|
||||
|
||||
// 令牌二次校验(getLyrics 为异步,期间可能切歌)
|
||||
if (token !== currentToken) return
|
||||
|
||||
const applied = applyLyricData(track, result.data, '最终', '最终')
|
||||
if (!applied && !hasRenderedProgress) {
|
||||
error.value = lyricMessages.value.unavailable
|
||||
lyrics.value = []
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (token !== currentToken) return
|
||||
if (hasRenderedProgress) {
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
console.error('[LyricManager] 获取歌词失败:', e)
|
||||
error.value = e.message || lyricMessages.value.fetchFailed
|
||||
lyrics.value = []
|
||||
} finally {
|
||||
// 只有当前令牌的请求才更新 loading 状态
|
||||
if (token === currentToken) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!managerScope) {
|
||||
managerScope = effectScope(true)
|
||||
managerScope.run(() => {
|
||||
watch(
|
||||
() => audioPlayer.getCurrentSong().value,
|
||||
(newTrack, oldTrack) => {
|
||||
// 歌曲切换(包括 id 变化和从有到无)
|
||||
const newId = newTrack?.id?.toString() ?? null
|
||||
const oldId = oldTrack?.id?.toString() ?? null
|
||||
if (newId === oldId) return
|
||||
|
||||
if (newTrack) {
|
||||
fetchLyric(newTrack)
|
||||
} else {
|
||||
currentToken++ // 使正在进行的请求失效
|
||||
clearLyrics()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
lyrics,
|
||||
loading,
|
||||
error,
|
||||
currentTrackId,
|
||||
hasTranslation,
|
||||
hasRoma,
|
||||
lyricFormat,
|
||||
lyricSource,
|
||||
fetchLyric,
|
||||
clearLyrics
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useTheme } from '~/composables/useTheme'
|
||||
|
||||
export interface LyricRenderConfig {
|
||||
fontSize: number
|
||||
lineHeight: number
|
||||
activeColor: string
|
||||
inactiveColor: string
|
||||
passedColor: string
|
||||
enableBlur: boolean
|
||||
enableScale: boolean
|
||||
enableSpring: boolean
|
||||
alignPosition: number
|
||||
}
|
||||
|
||||
export interface PlayerLyricLine {
|
||||
startTime: number
|
||||
endTime: number
|
||||
words: Array<{
|
||||
startTime: number
|
||||
endTime: number
|
||||
word: string
|
||||
romanWord?: string
|
||||
}>
|
||||
translatedLyric?: string
|
||||
romanLyric?: string
|
||||
isBG?: boolean
|
||||
isDuet?: boolean
|
||||
}
|
||||
|
||||
export const useLyricPlayer = () => {
|
||||
const lyricPlayer = ref<any | null>(null)
|
||||
const containerElement = ref<HTMLElement | null>(null)
|
||||
const isInitialized = ref(false)
|
||||
const currentLyrics = ref<PlayerLyricLine[]>([])
|
||||
const currentTime = ref(0)
|
||||
const isPlaying = ref(false)
|
||||
const onLineClickCallback = ref<((seconds: number) => void) | null>(null)
|
||||
|
||||
// 默认配置
|
||||
const defaultConfig: LyricRenderConfig = {
|
||||
fontSize: 24,
|
||||
lineHeight: 1.6,
|
||||
activeColor: 'var(--text-primary)',
|
||||
inactiveColor: 'var(--overlay-60)',
|
||||
passedColor: 'var(--overlay-40)',
|
||||
enableBlur: true,
|
||||
enableScale: true,
|
||||
enableSpring: true,
|
||||
alignPosition: 0.5
|
||||
}
|
||||
|
||||
// 将 config 中的 CSS 变量替换为实际颜色值(Canvas 无法直接解析 CSS 变量)
|
||||
const resolveCssVariables = (cfg: LyricRenderConfig): LyricRenderConfig => {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const resolve = (color: string, fallback = '#ffffff') => {
|
||||
if (!color.startsWith('var(')) return color
|
||||
const varName = color.match(/var\(--([a-z0-9-]+)\)/)?.[1]
|
||||
if (!varName) return color
|
||||
const resolved = style.getPropertyValue(varName).trim()
|
||||
return resolved || fallback
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
inactiveColor: resolve(cfg.inactiveColor, '#cccccc'),
|
||||
passedColor: resolve(cfg.passedColor, '#888888'),
|
||||
activeColor: resolve(cfg.activeColor, '#ffffff')
|
||||
}
|
||||
}
|
||||
|
||||
const config = ref<LyricRenderConfig>({ ...defaultConfig })
|
||||
|
||||
// 初始化歌词播放器
|
||||
const initializeLyricPlayer = async (container: HTMLElement) => {
|
||||
if (isInitialized.value || !container) return
|
||||
|
||||
try {
|
||||
containerElement.value = container
|
||||
console.log('[useLyricPlayer] 开始初始化歌词播放器,容器:', container)
|
||||
|
||||
// 动态导入 @applemusic-like-lyrics/core
|
||||
const { LyricPlayer } = await import('@applemusic-like-lyrics/core')
|
||||
console.log('[useLyricPlayer] LyricPlayer 类:', LyricPlayer)
|
||||
|
||||
if (!LyricPlayer) {
|
||||
throw new Error('LyricPlayer 类未找到')
|
||||
}
|
||||
|
||||
lyricPlayer.value = new LyricPlayer()
|
||||
console.log('[useLyricPlayer] LyricPlayer 实例创建成功')
|
||||
console.log(
|
||||
'[useLyricPlayer] 歌词播放器方法:',
|
||||
Object.getOwnPropertyNames(Object.getPrototypeOf(lyricPlayer.value))
|
||||
)
|
||||
|
||||
// 设置容器
|
||||
container.appendChild(lyricPlayer.value.getElement())
|
||||
console.log('[useLyricPlayer] 容器设置完成')
|
||||
|
||||
// 应用初始配置(解析 CSS 变量为实际颜色值)
|
||||
if (lyricPlayer.value.setConfig) {
|
||||
lyricPlayer.value.setConfig(resolveCssVariables(defaultConfig))
|
||||
}
|
||||
console.log('[useLyricPlayer] 配置应用完成')
|
||||
|
||||
// 注册行点击事件,实现交互跳转
|
||||
if (lyricPlayer.value && lyricPlayer.value.addEventListener) {
|
||||
lyricPlayer.value.addEventListener('line-click', (evt: any) => {
|
||||
try {
|
||||
const startMs =
|
||||
(evt?.line?.getLine ? evt.line.getLine().startTime : evt?.line?.startTime) ??
|
||||
evt?.detail?.startTime ??
|
||||
0
|
||||
const seconds = (startMs || 0) / 1000
|
||||
if (onLineClickCallback.value) {
|
||||
onLineClickCallback.value(seconds)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[useLyricPlayer] 处理 line-click 事件失败:', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
isInitialized.value = true
|
||||
console.log('[useLyricPlayer] 歌词播放器初始化成功')
|
||||
} catch (error) {
|
||||
console.error('[useLyricPlayer] 歌词播放器初始化失败:', error)
|
||||
// 如果初始化失败,创建一个简单的占位符
|
||||
createFallbackPlayer(container)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建备用播放器
|
||||
const createFallbackPlayer = (container: HTMLElement) => {
|
||||
container.innerHTML = `
|
||||
<div style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--overlay-60);
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
">
|
||||
<div>
|
||||
<div style="margin-bottom: 1rem;">🎵</div>
|
||||
<div>歌词播放器加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
isInitialized.value = true
|
||||
}
|
||||
|
||||
// 应用配置
|
||||
const applyConfig = async () => {
|
||||
if (!lyricPlayer.value || !lyricPlayer.value.setLyricLineStyle) return
|
||||
|
||||
try {
|
||||
// 解析 CSS 变量为实际颜色值(Canvas 无法直接解析)
|
||||
const resolved = resolveCssVariables(config.value)
|
||||
|
||||
// 设置字体大小和行高
|
||||
lyricPlayer.value.setLyricLineStyle({
|
||||
fontSize: resolved.fontSize,
|
||||
lineHeight: resolved.lineHeight
|
||||
})
|
||||
|
||||
// 设置颜色
|
||||
lyricPlayer.value.setLyricLineColor({
|
||||
active: resolved.activeColor,
|
||||
inactive: resolved.inactiveColor,
|
||||
passed: resolved.passedColor
|
||||
})
|
||||
|
||||
// 设置效果
|
||||
lyricPlayer.value.setLyricLineEffect({
|
||||
blur: resolved.enableBlur,
|
||||
scale: resolved.enableScale,
|
||||
spring: resolved.enableSpring
|
||||
})
|
||||
|
||||
// 设置对齐位置
|
||||
lyricPlayer.value.setAlignPosition(resolved.alignPosition)
|
||||
} catch (error) {
|
||||
console.error('应用歌词配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
const updateConfig = async (newConfig: Partial<LyricRenderConfig>) => {
|
||||
config.value = { ...config.value, ...newConfig }
|
||||
await applyConfig()
|
||||
}
|
||||
|
||||
// 设置歌词数据
|
||||
const setLyrics = async (lyrics: PlayerLyricLine[]) => {
|
||||
if (!lyricPlayer.value) {
|
||||
console.error('[useLyricPlayer] 歌词播放器未初始化')
|
||||
return
|
||||
}
|
||||
|
||||
if (!lyricPlayer.value.setLyricLines) {
|
||||
console.error('[useLyricPlayer] 歌词播放器缺少 setLyricLines 方法')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[useLyricPlayer] 开始设置歌词数据,行数:', lyrics.length)
|
||||
console.log('[useLyricPlayer] 第一行歌词示例:', lyrics[0])
|
||||
|
||||
currentLyrics.value = lyrics
|
||||
await lyricPlayer.value.setLyricLines(lyrics)
|
||||
console.log('[useLyricPlayer] 歌词数据设置成功')
|
||||
} catch (error) {
|
||||
console.error('[useLyricPlayer] 设置歌词数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新播放时间
|
||||
const updateTime = (time: number) => {
|
||||
if (!lyricPlayer.value || !lyricPlayer.value.setCurrentTime) return
|
||||
|
||||
try {
|
||||
currentTime.value = time
|
||||
// 确保时间是整数毫秒,符合 AMLL 要求
|
||||
const timeInMs = Math.floor(time)
|
||||
lyricPlayer.value.setCurrentTime(timeInMs)
|
||||
} catch (error) {
|
||||
console.error('[useLyricPlayer] 更新播放时间失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新歌词播放器(类似于 applemusic-like-lyrics-page-main 中的 update 方法)
|
||||
const update = (deltaTime: number) => {
|
||||
if (!lyricPlayer.value || !lyricPlayer.value.update) return
|
||||
|
||||
try {
|
||||
lyricPlayer.value.update(deltaTime)
|
||||
} catch (error) {
|
||||
console.error('[useLyricPlayer] 更新歌词播放器失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置播放状态
|
||||
const setPlayingState = (playing: boolean) => {
|
||||
if (!lyricPlayer.value) return
|
||||
|
||||
try {
|
||||
isPlaying.value = playing
|
||||
if (playing && lyricPlayer.value.resume) {
|
||||
lyricPlayer.value.resume()
|
||||
} else if (!playing && lyricPlayer.value.pause) {
|
||||
lyricPlayer.value.pause()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('设置播放状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到指定时间
|
||||
const seekTo = (time: number) => {
|
||||
if (!lyricPlayer.value || !lyricPlayer.value.setCurrentTime) return
|
||||
|
||||
try {
|
||||
// 确保时间是整数毫秒,符合 AMLL 要求,并标记为 seek 操作
|
||||
const timeInMs = Math.floor(time)
|
||||
|
||||
// 强制中断当前动画 - 先暂停播放状态
|
||||
const wasPlaying = isPlaying.value
|
||||
if (lyricPlayer.value.pause) {
|
||||
lyricPlayer.value.pause()
|
||||
}
|
||||
|
||||
// 立即跳转到目标时间,第二个参数 true 表示这是一个 seek 操作
|
||||
lyricPlayer.value.setCurrentTime(timeInMs, true)
|
||||
|
||||
// 如果之前是播放状态,恢复播放状态
|
||||
if (wasPlaying && lyricPlayer.value.resume) {
|
||||
// 使用短暂延迟确保时间跳转完成后再恢复播放状态
|
||||
setTimeout(() => {
|
||||
if (lyricPlayer.value && lyricPlayer.value.resume) {
|
||||
lyricPlayer.value.resume()
|
||||
}
|
||||
}, 50) // 50ms 延迟确保跳转完成
|
||||
}
|
||||
|
||||
currentTime.value = time
|
||||
console.log('[useLyricPlayer] 跳转到时间:', timeInMs, 'ms')
|
||||
} catch (error) {
|
||||
console.error('跳转时间失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 对外暴露:注册歌词行点击回调
|
||||
const onLineClick = (cb: (seconds: number) => void) => {
|
||||
onLineClickCallback.value = cb
|
||||
}
|
||||
|
||||
// 清理资源
|
||||
const dispose = () => {
|
||||
if (lyricPlayer.value && lyricPlayer.value.dispose) {
|
||||
try {
|
||||
lyricPlayer.value.dispose()
|
||||
lyricPlayer.value = null
|
||||
} catch (error) {
|
||||
console.error('清理歌词播放器失败:', error)
|
||||
}
|
||||
}
|
||||
isInitialized.value = false
|
||||
containerElement.value = null
|
||||
}
|
||||
|
||||
// 监听配置变化
|
||||
watch(
|
||||
config,
|
||||
async (newConfig) => {
|
||||
await applyConfig()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 监听主题切换:响应式监听 currentTheme 变化,重新解析 CSS 变量颜色
|
||||
const { currentTheme } = useTheme()
|
||||
watch(
|
||||
() => currentTheme.value,
|
||||
async () => {
|
||||
await applyConfig()
|
||||
}
|
||||
)
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
lyricPlayer: computed(() => lyricPlayer.value),
|
||||
isInitialized: computed(() => isInitialized.value),
|
||||
currentLyrics: computed(() => currentLyrics.value),
|
||||
currentTime: computed(() => currentTime.value),
|
||||
isPlaying: computed(() => isPlaying.value),
|
||||
update,
|
||||
config: computed(() => config.value),
|
||||
|
||||
// 方法
|
||||
initializeLyricPlayer,
|
||||
updateConfig,
|
||||
setLyrics,
|
||||
updateTime,
|
||||
setPlayingState,
|
||||
seekTo,
|
||||
dispose,
|
||||
applyConfig,
|
||||
onLineClick
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
export const useLyricSettings = () => {
|
||||
// 根据设备类型设置默认字体大小
|
||||
const getDefaultFontSize = () => {
|
||||
if (typeof window === 'undefined') return 44
|
||||
return window.innerWidth <= 1024 ? 30 : 44
|
||||
}
|
||||
|
||||
const getDefaultTranFontSize = () => {
|
||||
if (typeof window === 'undefined') return 22
|
||||
return window.innerWidth <= 1024 ? 18 : 22
|
||||
}
|
||||
|
||||
const getDefaultRomaFontSize = () => {
|
||||
if (typeof window === 'undefined') return 18
|
||||
return window.innerWidth <= 1024 ? 14 : 18
|
||||
}
|
||||
|
||||
const lyricFontSize = useState('lyric-font-size', getDefaultFontSize)
|
||||
const lyricTranFontSize = useState('lyric-tran-font-size', getDefaultTranFontSize)
|
||||
const lyricRomaFontSize = useState('lyric-roma-font-size', getDefaultRomaFontSize)
|
||||
const lyricFontSizeMode = useState('lyric-font-size-mode', () => 'auto') // auto | custom
|
||||
const useAMLyrics = useState('lyric-use-am-style', () => true)
|
||||
const useAMSpring = useState('lyric-use-spring', () => true)
|
||||
const lyricAlignRight = useState('lyric-align-right', () => false)
|
||||
const showTranslation = useState('lyric-show-translation', () => true)
|
||||
const showRoma = useState('lyric-show-roma', () => true)
|
||||
const showWordsRoma = useState('lyric-show-words-roma', () => true) // 逐字罗马音
|
||||
const swapTranRoma = useState('lyric-swap-tran-roma', () => false)
|
||||
const lyricOffset = useState('lyric-offset', () => 0)
|
||||
|
||||
// 括号替换设置
|
||||
const replaceLyricBrackets = useState('lyric-replace-brackets', () => true)
|
||||
const bracketReplacementPreset = useState('lyric-bracket-preset', () => 'dash')
|
||||
const customBracketReplacement = useState('lyric-custom-bracket', () => '-')
|
||||
|
||||
// 歌词模糊与缩放
|
||||
const lyricsBlur = useState('lyric-blur', () => true)
|
||||
const lyricsScrollOffset = useState('lyric-scroll-offset', () => 0.5)
|
||||
const hidePassedLines = useState('lyric-hide-passed', () => false)
|
||||
const wordFadeWidth = useState('lyric-word-fade-width', () => 0.5)
|
||||
const countDownShow = useState('lyric-countdown-show', () => true)
|
||||
|
||||
// AMLL 歌词优化
|
||||
const amllNormalizeSpaces = useState('lyric-amll-normalize-spaces', () => true)
|
||||
const amllResetLineTimestamps = useState('lyric-amll-reset-line-timestamps', () => true)
|
||||
const amllConvertBgLines = useState('lyric-amll-convert-bg-lines', () => true)
|
||||
const amllSyncBgLines = useState('lyric-amll-sync-bg-lines', () => true)
|
||||
const amllCleanOverlaps = useState('lyric-amll-clean-overlaps', () => true)
|
||||
const amllTryAdvanceStart = useState('lyric-amll-try-advance-start', () => true)
|
||||
|
||||
// 歌词字体
|
||||
const lyricFontWeight = useState('lyric-font-weight', () => 'bold')
|
||||
const LyricFont = useState('lyric-font-family', () => 'follow') // 'follow' 跟随系统
|
||||
|
||||
// 歌词排除规则
|
||||
const enableExcludeLyrics = useState('lyric-exclude-enable', () => false)
|
||||
const excludeLyricsUserKeywords = useState<string[]>('lyric-exclude-keywords', () => [])
|
||||
const excludeLyricsUserRegexes = useState<string[]>('lyric-exclude-regexes', () => [])
|
||||
const enableExcludeLyricsTTML = useState('lyric-exclude-ttml', () => false)
|
||||
const enableExcludeLyricsLocal = useState('lyric-exclude-local', () => false)
|
||||
|
||||
// 优先级
|
||||
const lyricPriority = useState<'qm' | 'official' | 'ttml' | 'auto'>(
|
||||
'lyric-priority',
|
||||
() => 'auto'
|
||||
)
|
||||
const enableQQMusicLyric = useState('lyric-enable-qm', () => true)
|
||||
const enableOnlineTTMLLyric = useState('lyric-enable-ttml', () => true)
|
||||
// AMLL TTML DB Server 配置
|
||||
const amllDbServer = useState(
|
||||
'lyric-amll-db-server',
|
||||
() => 'https://amlldb.bikonoo.com/ncm-lyrics/%s.ttml'
|
||||
)
|
||||
const localLyricQQMusicMatch = useState('lyric-local-qm-match', () => true)
|
||||
const localLyricPath = useState<string[]>('lyric-local-path', () => [])
|
||||
|
||||
// 繁简转换
|
||||
const preferTraditionalChinese = useState('lyric-prefer-traditional', () => false)
|
||||
const traditionalChineseVariant = useState('lyric-traditional-variant', () => 'hk')
|
||||
|
||||
// 显示 YRC
|
||||
const showYrc = useState('lyric-show-yrc', () => true)
|
||||
const lyricHorizontalOffset = useState('lyric-horizontal-offset', () => 0)
|
||||
|
||||
// 全屏播放器元素开关
|
||||
const fullscreenPlayerElements = useState('fullscreen-elements', () => ({
|
||||
copyLyric: true,
|
||||
lyricOffset: true,
|
||||
lyricSettings: true
|
||||
}))
|
||||
|
||||
const lyricOffsetStep = useState('lyric-offset-step', () => 100)
|
||||
|
||||
// 根据屏幕尺寸调整字体大小
|
||||
const adjustFontSizeForDevice = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const isMobile = window.innerWidth <= 1024
|
||||
|
||||
// 只在 auto 模式下自动调整
|
||||
if (lyricFontSizeMode.value === 'auto') {
|
||||
lyricFontSize.value = isMobile ? 30 : 44
|
||||
lyricTranFontSize.value = isMobile ? 18 : 22
|
||||
lyricRomaFontSize.value = isMobile ? 14 : 18
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lyricFontSize,
|
||||
lyricTranFontSize,
|
||||
lyricRomaFontSize,
|
||||
lyricFontSizeMode,
|
||||
useAMLyrics,
|
||||
useAMSpring,
|
||||
lyricAlignRight,
|
||||
showTranslation,
|
||||
showRoma,
|
||||
showWordsRoma,
|
||||
swapTranRoma,
|
||||
lyricOffset,
|
||||
replaceLyricBrackets,
|
||||
bracketReplacementPreset,
|
||||
customBracketReplacement,
|
||||
lyricsBlur,
|
||||
lyricsScrollOffset,
|
||||
hidePassedLines,
|
||||
wordFadeWidth,
|
||||
countDownShow,
|
||||
amllNormalizeSpaces,
|
||||
amllResetLineTimestamps,
|
||||
amllConvertBgLines,
|
||||
amllSyncBgLines,
|
||||
amllCleanOverlaps,
|
||||
amllTryAdvanceStart,
|
||||
lyricFontWeight,
|
||||
LyricFont,
|
||||
enableExcludeLyrics,
|
||||
excludeLyricsUserKeywords,
|
||||
excludeLyricsUserRegexes,
|
||||
enableExcludeLyricsTTML,
|
||||
enableExcludeLyricsLocal,
|
||||
lyricPriority,
|
||||
enableQQMusicLyric,
|
||||
enableOnlineTTMLLyric,
|
||||
amllDbServer,
|
||||
localLyricQQMusicMatch,
|
||||
localLyricPath,
|
||||
preferTraditionalChinese,
|
||||
traditionalChineseVariant,
|
||||
showYrc,
|
||||
lyricHorizontalOffset,
|
||||
fullscreenPlayerElements,
|
||||
lyricOffsetStep,
|
||||
adjustFontSizeForDevice
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
import { cleanTTMLTranslations, parseQRCLyric, parseSmartLrc } from '~/utils/lyric/lyricParser'
|
||||
import { parseTTML, parseYrc } from '@applemusic-like-lyrics/lyric'
|
||||
import { useAudioPlayer } from './useAudioPlayer'
|
||||
import { useMusicSources } from './useMusicSources'
|
||||
import { useLyricSettings } from './useLyricSettings'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
export interface ParsedLyricLine {
|
||||
time: number
|
||||
content: string
|
||||
words?: Array<{
|
||||
time: number
|
||||
duration: number
|
||||
content: string
|
||||
}>
|
||||
}
|
||||
|
||||
type ParsedLyricWord = NonNullable<ParsedLyricLine['words']>[number]
|
||||
|
||||
export interface LyricData {
|
||||
lrc: string
|
||||
trans?: string
|
||||
yrc?: string
|
||||
ttml?: string
|
||||
/** YRC 对齐的翻译(ytlrc),时间戳与逐字主歌词行边界一致 */
|
||||
ytrans?: string
|
||||
}
|
||||
|
||||
// ─── 内部解析工具 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 将 @applemusic-like-lyrics/lyric 库返回的 lines 数组统一转换为
|
||||
* ParsedLyricLine[],兼容 TTML / YRC / SmartLrc 三种来源。
|
||||
*/
|
||||
const convertLibraryLines = (lines: any[]): ParsedLyricLine[] => {
|
||||
return lines
|
||||
.map((line): ParsedLyricLine | null => {
|
||||
const words: ParsedLyricWord[] | undefined = Array.isArray(line?.words)
|
||||
? line.words
|
||||
.map((word: any): ParsedLyricWord | null => {
|
||||
const startTime = typeof word?.startTime === 'number' ? word.startTime : 0
|
||||
const endTime =
|
||||
typeof word?.endTime === 'number'
|
||||
? word.endTime
|
||||
: startTime + (typeof word?.duration === 'number' ? word.duration : 0)
|
||||
const content =
|
||||
typeof word?.word === 'string' ? word.word : String(word?.content ?? '')
|
||||
if (!content) return null
|
||||
return { time: startTime, duration: Math.max(endTime - startTime, 0), content }
|
||||
})
|
||||
.filter((w: ParsedLyricWord | null): w is ParsedLyricWord => w !== null)
|
||||
: undefined
|
||||
|
||||
const content =
|
||||
words?.map((w) => w.content).join('') ||
|
||||
(typeof line?.content === 'string' ? line.content : '')
|
||||
|
||||
const time = typeof line?.startTime === 'number' ? line.startTime : (line?.time ?? 0)
|
||||
|
||||
if (!content && (!words || words.length === 0)) return null
|
||||
return { time, content, words: words && words.length > 0 ? words : undefined }
|
||||
})
|
||||
.filter((l): l is ParsedLyricLine => l !== null)
|
||||
.sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
const hasWordByWord = (lines: ParsedLyricLine[]): boolean =>
|
||||
lines.some((l) => (l.words?.length ?? 0) > 1)
|
||||
|
||||
/**
|
||||
* 尝试将单段文本解析为歌词行。
|
||||
* 优先级:TTML > QRC > YRC(JSON) > SmartLrc
|
||||
*/
|
||||
const parseFlexibleLyrics = (text: string): { lines: ParsedLyricLine[]; wordByWord: boolean } => {
|
||||
const raw = text?.trim()
|
||||
if (!raw) return { lines: [], wordByWord: false }
|
||||
|
||||
// TTML(XML 开头)
|
||||
if (raw.startsWith('<')) {
|
||||
// 先尝试 TTML
|
||||
try {
|
||||
const parsed = parseTTML(cleanTTMLTranslations(raw))
|
||||
const ttmlLines = convertLibraryLines((parsed as any)?.lines ?? [])
|
||||
if (ttmlLines.length > 0) {
|
||||
return { lines: ttmlLines, wordByWord: hasWordByWord(ttmlLines) }
|
||||
}
|
||||
} catch {
|
||||
// 解析失败继续尝试 QRC
|
||||
}
|
||||
|
||||
// QRC(QQ 音乐 XML 格式)
|
||||
if (raw.includes('LyricContent="') || raw.includes('<lyric') || raw.includes('<LrcContent')) {
|
||||
try {
|
||||
const qrcLines = convertLibraryLines(parseQRCLyric(raw))
|
||||
if (qrcLines.length > 0) {
|
||||
return { lines: qrcLines, wordByWord: hasWordByWord(qrcLines) }
|
||||
}
|
||||
} catch {
|
||||
// 继续回退
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// YRC(网易云 JSON 逐字格式)
|
||||
if (raw.startsWith('{') || raw.trim().split('\n')[0]?.trimStart().startsWith('{')) {
|
||||
try {
|
||||
const lines = parseYrc(raw)
|
||||
if (lines && lines.length > 0) {
|
||||
const converted = convertLibraryLines(lines)
|
||||
if (converted.length > 0) {
|
||||
return { lines: converted, wordByWord: hasWordByWord(converted) }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 回退到 SmartLrc
|
||||
}
|
||||
}
|
||||
|
||||
// SmartLrc(通用 LRC / 逐字扩展 LRC)
|
||||
const { lines: smartLines, format } = parseSmartLrc(raw)
|
||||
if (smartLines.length > 0) {
|
||||
const converted = convertLibraryLines(smartLines)
|
||||
return {
|
||||
lines: converted,
|
||||
wordByWord: hasWordByWord(converted) || format !== 'line'
|
||||
}
|
||||
}
|
||||
|
||||
return { lines: [], wordByWord: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* 按优先级顺序尝试多个候选文本,返回第一个成功解析的结果。
|
||||
* 外部调用时按 [ttml, yrc, lrc] 顺序传入。
|
||||
*/
|
||||
const parseBestLyrics = (
|
||||
candidates: Array<string | undefined | null>
|
||||
): { lines: ParsedLyricLine[]; wordByWord: boolean } => {
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate?.trim()) continue
|
||||
const parsed = parseFlexibleLyrics(candidate)
|
||||
if (parsed.lines.length > 0) return parsed
|
||||
}
|
||||
return { lines: [], wordByWord: false }
|
||||
}
|
||||
|
||||
// ─── Composable ────────────────────────────────────────────────
|
||||
|
||||
export const useLyrics = () => {
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const { composableErrors } = useLocale()
|
||||
const lyricMessages = computed(() => composableErrors.value.lyrics)
|
||||
|
||||
const currentLyrics = ref<ParsedLyricLine[]>([])
|
||||
const translationLyrics = ref<ParsedLyricLine[]>([])
|
||||
const wordByWordLyrics = ref<ParsedLyricLine[]>([])
|
||||
const currentLyricIndex = ref(0)
|
||||
const currentTime = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const showTranslation = ref(false)
|
||||
const showWordByWord = ref(false)
|
||||
|
||||
// 竞态令牌
|
||||
let currentToken = 0
|
||||
|
||||
// ─── 通知 HarmonyOS ────────────────────────────────────────────
|
||||
|
||||
const notifyHarmonyOSLyricsUpdate = (lyrics: string) => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (!window.HarmonyOS?.postMessage) {
|
||||
console.warn('[useLyrics] 鸿蒙侧消息接口不可用')
|
||||
return
|
||||
}
|
||||
try {
|
||||
window.HarmonyOS.postMessage(
|
||||
JSON.stringify({ method: 'updateLyrics', parameters: { lyrics } })
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('[useLyrics] 通知鸿蒙侧失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 歌词获取 ────────────────────────────────────────────────
|
||||
|
||||
const applyLyricData = (lyricData?: LyricData | null): boolean => {
|
||||
if (!lyricData) return false
|
||||
|
||||
// 主歌词:按优先级 ttml > yrc > lrc 解析
|
||||
const parsedMain = parseBestLyrics([lyricData.ttml, lyricData.yrc, lyricData.lrc])
|
||||
if (parsedMain.lines.length === 0) return false
|
||||
|
||||
currentLyrics.value = parsedMain.lines
|
||||
wordByWordLyrics.value = parsedMain.wordByWord ? parsedMain.lines : []
|
||||
showWordByWord.value = parsedMain.wordByWord
|
||||
|
||||
translationLyrics.value = []
|
||||
showTranslation.value = false
|
||||
|
||||
// 翻译歌词(ytlrc 时间戳与逐字主歌词对齐,优先于 tlyric)
|
||||
const transSource = lyricData.ytrans || lyricData.trans
|
||||
if (transSource) {
|
||||
const parsedTrans = parseBestLyrics([transSource])
|
||||
if (parsedTrans.lines.length > 0) {
|
||||
translationLyrics.value = parsedTrans.lines
|
||||
showTranslation.value = true
|
||||
}
|
||||
}
|
||||
|
||||
error.value = null
|
||||
return true
|
||||
}
|
||||
|
||||
const fetchLyrics = async (
|
||||
platform: string,
|
||||
musicId: string,
|
||||
meta?: { title?: string; artist?: string; album?: string }
|
||||
): Promise<void> => {
|
||||
if (!platform || !musicId) {
|
||||
console.error('[useLyrics] fetchLyrics 参数错误:', { platform, musicId })
|
||||
error.value = lyricMessages.value.missingParams
|
||||
_resetState()
|
||||
return
|
||||
}
|
||||
|
||||
const token = ++currentToken
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
_resetState()
|
||||
let hasRenderedProgress = false
|
||||
|
||||
try {
|
||||
const { getLyrics } = useMusicSources()
|
||||
const currentSong = audioPlayer.getCurrentSong().value as any
|
||||
const result = await getLyrics(platform as 'netease' | 'tencent', musicId, {
|
||||
title: meta?.title ?? currentSong?.title ?? '',
|
||||
artist: meta?.artist ?? currentSong?.artist ?? '',
|
||||
album: meta?.album ?? currentSong?.album ?? '',
|
||||
duration: currentSong?.duration,
|
||||
priority: useLyricSettings().lyricPriority.value,
|
||||
onProgress: ({ data }) => {
|
||||
if (token !== currentToken) return
|
||||
const applied = applyLyricData(data)
|
||||
if (!applied) return
|
||||
|
||||
hasRenderedProgress = true
|
||||
isLoading.value = false
|
||||
notifyHarmonyOSLyricsUpdate(getFormattedLyricsForHarmonyOS())
|
||||
}
|
||||
})
|
||||
|
||||
if (token !== currentToken) return
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.error || lyricMessages.value.notFound)
|
||||
}
|
||||
|
||||
const lyricData = result.data
|
||||
|
||||
const applied = applyLyricData(lyricData)
|
||||
if (!applied && !hasRenderedProgress) {
|
||||
throw new Error(lyricMessages.value.notFound)
|
||||
}
|
||||
|
||||
const harmonyLyrics = getFormattedLyricsForHarmonyOS()
|
||||
notifyHarmonyOSLyricsUpdate(harmonyLyrics)
|
||||
|
||||
error.value = null
|
||||
} catch (e: any) {
|
||||
if (token !== currentToken) return
|
||||
if (hasRenderedProgress) {
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
console.error('[useLyrics] 获取歌词失败:', e?.message ?? e)
|
||||
error.value = e?.message ?? lyricMessages.value.fetchFailed
|
||||
_resetState()
|
||||
notifyHarmonyOSLyricsUpdate('')
|
||||
} finally {
|
||||
if (token === currentToken) {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 时间同步 ────────────────────────────────────────────────
|
||||
|
||||
const updateCurrentLyricIndex = (time: number): void => {
|
||||
currentTime.value = time
|
||||
const lyricList = currentLyrics.value
|
||||
if (lyricList.length === 0) return
|
||||
|
||||
// 二分查找当前时间所在行
|
||||
let lo = 0
|
||||
let hi = lyricList.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1
|
||||
if (lyricList[mid].time <= time) lo = mid
|
||||
else hi = mid - 1
|
||||
}
|
||||
|
||||
if (currentLyricIndex.value !== lo) {
|
||||
currentLyricIndex.value = lo
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 计算属性 ────────────────────────────────────────────────
|
||||
|
||||
const currentLyricContent = computed(() => {
|
||||
const lyricList = currentLyrics.value
|
||||
if (lyricList.length === 0 || currentLyricIndex.value >= lyricList.length) return ''
|
||||
return lyricList[currentLyricIndex.value].content
|
||||
})
|
||||
|
||||
const currentTranslationContent = computed(() => {
|
||||
if (!showTranslation.value || translationLyrics.value.length === 0) return ''
|
||||
const refTime = currentLyrics.value[currentLyricIndex.value]?.time
|
||||
if (refTime == null) return ''
|
||||
|
||||
const list = translationLyrics.value
|
||||
let lo = 0
|
||||
let hi = list.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1
|
||||
if (list[mid].time <= refTime) lo = mid
|
||||
else hi = mid - 1
|
||||
}
|
||||
return list[lo]?.time <= refTime ? list[lo].content : ''
|
||||
})
|
||||
|
||||
// ─── 格式化工具 ────────────────────────────────────────────────
|
||||
|
||||
const _formatTime = (ms: number): string => {
|
||||
const minutes = Math.floor(ms / 60000)
|
||||
const seconds = Math.floor((ms % 60000) / 1000)
|
||||
const centiseconds = Math.floor((ms % 1000) / 10)
|
||||
return `[${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(centiseconds).padStart(2, '0')}]`
|
||||
}
|
||||
|
||||
const getFormattedLrcText = (): string => {
|
||||
if (currentLyrics.value.length === 0) return `${_formatTime(0)}${lyricMessages.value.unavailable}`
|
||||
return currentLyrics.value
|
||||
.map((line) => `${_formatTime(line.time)}${line.content}`)
|
||||
.join('\r\n')
|
||||
}
|
||||
|
||||
const getFormattedLyricsForHarmonyOS = (): string => {
|
||||
if (currentLyrics.value.length === 0) return `${_formatTime(0)}${lyricMessages.value.unavailable}`
|
||||
|
||||
const MAX_BYTES = 40960
|
||||
const encoder = new TextEncoder()
|
||||
const lines = currentLyrics.value
|
||||
const parts: string[] = []
|
||||
let byteCount = 0
|
||||
|
||||
for (const line of lines) {
|
||||
const lineText = `${_formatTime(line.time)}${line.content}\r\n`
|
||||
const lineBytes = encoder.encode(lineText).length
|
||||
// 留 100 字节缓冲
|
||||
if (byteCount + lineBytes > MAX_BYTES - 100) break
|
||||
parts.push(lineText)
|
||||
byteCount += lineBytes
|
||||
}
|
||||
|
||||
return parts.join('').trimEnd()
|
||||
}
|
||||
|
||||
// ─── 清理 ────────────────────────────────────────────────────
|
||||
|
||||
const _resetState = () => {
|
||||
currentLyrics.value = []
|
||||
translationLyrics.value = []
|
||||
wordByWordLyrics.value = []
|
||||
currentLyricIndex.value = 0
|
||||
showTranslation.value = false
|
||||
showWordByWord.value = false
|
||||
}
|
||||
|
||||
const clearLyrics = (): void => {
|
||||
currentToken++
|
||||
_resetState()
|
||||
currentTime.value = 0
|
||||
error.value = null
|
||||
}
|
||||
|
||||
const seekToLyricLine = (index: number): number => {
|
||||
if (index >= 0 && index < currentLyrics.value.length) {
|
||||
return currentLyrics.value[index].time / 1000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 保留向后兼容的解析方法(部分组件可能直接调用)
|
||||
const parseLrcLyrics = (lrcText: string): ParsedLyricLine[] => {
|
||||
if (!lrcText) return []
|
||||
return parseFlexibleLyrics(lrcText).lines
|
||||
}
|
||||
|
||||
const parseYrcLyrics = (yrcText: string): ParsedLyricLine[] => {
|
||||
if (!yrcText) return []
|
||||
return parseFlexibleLyrics(yrcText).lines
|
||||
}
|
||||
|
||||
return {
|
||||
currentLyrics: readonly(currentLyrics),
|
||||
translationLyrics: readonly(translationLyrics),
|
||||
wordByWordLyrics: readonly(wordByWordLyrics),
|
||||
currentLyricIndex: readonly(currentLyricIndex),
|
||||
currentTime: readonly(currentTime),
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
|
||||
showTranslation,
|
||||
showWordByWord,
|
||||
|
||||
currentLyricContent,
|
||||
currentTranslationContent,
|
||||
|
||||
fetchLyrics,
|
||||
updateCurrentLyricIndex,
|
||||
getFormattedLrcText,
|
||||
getFormattedLyricsForHarmonyOS,
|
||||
notifyHarmonyOSLyricsUpdate,
|
||||
clearLyrics,
|
||||
seekToLyricLine,
|
||||
parseLrcLyrics,
|
||||
parseYrcLyrics
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { onMounted, onUnmounted, readonly, ref } from 'vue'
|
||||
|
||||
interface MediaSessionSong {
|
||||
id: number
|
||||
title: string
|
||||
artist: string
|
||||
cover?: string | null
|
||||
musicUrl?: string | null
|
||||
}
|
||||
|
||||
interface MediaSessionCallbacks {
|
||||
onPlay?: () => void
|
||||
onPause?: () => void
|
||||
onStop?: () => void
|
||||
onSeekBackward?: (details: MediaSessionActionDetails) => void
|
||||
onSeekForward?: (details: MediaSessionActionDetails) => void
|
||||
onSeekTo?: (details: MediaSessionActionDetails) => void
|
||||
onPreviousTrack?: () => void
|
||||
onNextTrack?: () => void
|
||||
}
|
||||
|
||||
export const useMediaSession = () => {
|
||||
const isSupported = ref(false)
|
||||
const isActive = ref(false)
|
||||
const currentSong = ref<MediaSessionSong | null>(null)
|
||||
const callbacks = ref<MediaSessionCallbacks>({})
|
||||
|
||||
// 检查浏览器是否支持 Media Session API
|
||||
const checkSupport = () => {
|
||||
isSupported.value = 'mediaSession' in navigator
|
||||
return isSupported.value
|
||||
}
|
||||
|
||||
// 设置媒体元数据
|
||||
const setMetadata = (song: MediaSessionSong) => {
|
||||
if (!isSupported.value || !song) return
|
||||
|
||||
currentSong.value = song
|
||||
|
||||
try {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: song.title || '未知歌曲',
|
||||
artist: song.artist || '未知艺术家',
|
||||
album: '', // 可以根据需要添加专辑信息
|
||||
artwork: song.cover
|
||||
? [
|
||||
{
|
||||
src: song.cover,
|
||||
sizes: '96x96',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: song.cover,
|
||||
sizes: '128x128',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: song.cover,
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: song.cover,
|
||||
sizes: '256x256',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: song.cover,
|
||||
sizes: '384x384',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: song.cover,
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('设置媒体元数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置播放状态
|
||||
const setPlaybackState = (state: MediaSessionPlaybackState) => {
|
||||
if (!isSupported.value) return
|
||||
|
||||
try {
|
||||
navigator.mediaSession.playbackState = state
|
||||
isActive.value = state !== 'none'
|
||||
} catch (error) {
|
||||
console.warn('设置播放状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置位置状态
|
||||
const setPositionState = (duration: number, playbackRate: number = 1, position: number = 0) => {
|
||||
if (!isSupported.value || !navigator.mediaSession.setPositionState) return
|
||||
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: Math.max(0, duration),
|
||||
playbackRate: Math.max(0, playbackRate),
|
||||
position: Math.max(0, Math.min(position, duration))
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('设置位置状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置动作处理器
|
||||
const setActionHandlers = (newCallbacks: MediaSessionCallbacks) => {
|
||||
if (!isSupported.value) return
|
||||
|
||||
callbacks.value = { ...callbacks.value, ...newCallbacks }
|
||||
|
||||
try {
|
||||
// 播放/暂停
|
||||
if (callbacks.value.onPlay) {
|
||||
navigator.mediaSession.setActionHandler('play', callbacks.value.onPlay)
|
||||
}
|
||||
if (callbacks.value.onPause) {
|
||||
navigator.mediaSession.setActionHandler('pause', callbacks.value.onPause)
|
||||
}
|
||||
if (callbacks.value.onStop) {
|
||||
navigator.mediaSession.setActionHandler('stop', callbacks.value.onStop)
|
||||
}
|
||||
|
||||
// 跳转控制
|
||||
if (callbacks.value.onSeekBackward) {
|
||||
navigator.mediaSession.setActionHandler('seekbackward', callbacks.value.onSeekBackward)
|
||||
}
|
||||
if (callbacks.value.onSeekForward) {
|
||||
navigator.mediaSession.setActionHandler('seekforward', callbacks.value.onSeekForward)
|
||||
}
|
||||
if (callbacks.value.onSeekTo) {
|
||||
navigator.mediaSession.setActionHandler('seekto', callbacks.value.onSeekTo)
|
||||
}
|
||||
|
||||
// 上一首/下一首
|
||||
if (callbacks.value.onPreviousTrack) {
|
||||
navigator.mediaSession.setActionHandler('previoustrack', callbacks.value.onPreviousTrack)
|
||||
}
|
||||
if (callbacks.value.onNextTrack) {
|
||||
navigator.mediaSession.setActionHandler('nexttrack', callbacks.value.onNextTrack)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('设置动作处理器失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有动作处理器
|
||||
const clearActionHandlers = () => {
|
||||
if (!isSupported.value) return
|
||||
|
||||
try {
|
||||
const actions: MediaSessionAction[] = [
|
||||
'play',
|
||||
'pause',
|
||||
'stop',
|
||||
'seekbackward',
|
||||
'seekforward',
|
||||
'seekto',
|
||||
'previoustrack',
|
||||
'nexttrack'
|
||||
]
|
||||
|
||||
actions.forEach((action) => {
|
||||
navigator.mediaSession.setActionHandler(action, null)
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('清除动作处理器失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 Media Session
|
||||
const initialize = (song?: MediaSessionSong, actionCallbacks?: MediaSessionCallbacks) => {
|
||||
if (!checkSupport()) {
|
||||
console.warn('当前浏览器不支持 Media Session API')
|
||||
return false
|
||||
}
|
||||
|
||||
if (song) {
|
||||
setMetadata(song)
|
||||
}
|
||||
|
||||
if (actionCallbacks) {
|
||||
setActionHandlers(actionCallbacks)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 清理 Media Session
|
||||
const cleanup = () => {
|
||||
if (!isSupported.value) return
|
||||
|
||||
try {
|
||||
clearActionHandlers()
|
||||
navigator.mediaSession.metadata = null
|
||||
setPlaybackState('none')
|
||||
isActive.value = false
|
||||
currentSong.value = null
|
||||
} catch (error) {
|
||||
console.warn('清理 Media Session 失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新歌曲信息
|
||||
const updateSong = (song: MediaSessionSong) => {
|
||||
if (!isSupported.value) return
|
||||
setMetadata(song)
|
||||
}
|
||||
|
||||
// 更新播放状态
|
||||
const updatePlaybackState = (isPlaying: boolean) => {
|
||||
if (!isSupported.value) return
|
||||
setPlaybackState(isPlaying ? 'playing' : 'paused')
|
||||
}
|
||||
|
||||
// 更新播放进度
|
||||
const updatePosition = (position: number, duration: number, playbackRate: number = 1) => {
|
||||
if (!isSupported.value) return
|
||||
setPositionState(duration, playbackRate, position)
|
||||
}
|
||||
|
||||
// 组件挂载时检查支持
|
||||
onMounted(() => {
|
||||
checkSupport()
|
||||
})
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isSupported: readonly(isSupported),
|
||||
isActive: readonly(isActive),
|
||||
currentSong: readonly(currentSong),
|
||||
|
||||
// 方法
|
||||
initialize,
|
||||
cleanup,
|
||||
updateSong,
|
||||
updatePlaybackState,
|
||||
updatePosition,
|
||||
setActionHandlers,
|
||||
clearActionHandlers,
|
||||
|
||||
// 底层方法(高级用法)
|
||||
setMetadata,
|
||||
setPlaybackState,
|
||||
setPositionState
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
||||
import { onBeforeUnmount, onMounted, ref, readonly } from 'vue'
|
||||
|
||||
interface MusicState {
|
||||
songId?: number
|
||||
isPlaying: boolean
|
||||
position: number
|
||||
duration: number
|
||||
volume: number
|
||||
playlistIndex?: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface SongInfo {
|
||||
songId?: number
|
||||
title: string
|
||||
artist: string
|
||||
cover: string
|
||||
duration: number
|
||||
playlistIndex?: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export function useMusicWebSocket() {
|
||||
const isConnected = ref(false)
|
||||
const connectionId = ref<string | null>(null)
|
||||
const lastHeartbeat = ref<number>(0)
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
let reconnectTimer: NodeJS.Timeout | null = null
|
||||
let heartbeatTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// 事件回调
|
||||
const onStateUpdate = ref<((state: MusicState) => void) | null>(null)
|
||||
const onSongChange = ref<((songInfo: SongInfo) => void) | null>(null)
|
||||
const onPlaylistUpdate = ref<((playlist: any[]) => void) | null>(null)
|
||||
const onConnectionChange = ref<((connected: boolean) => void) | null>(null)
|
||||
|
||||
// 连接WebSocket
|
||||
const connect = (token?: string) => {
|
||||
if (eventSource) {
|
||||
disconnect()
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL('/api/music/websocket', window.location.origin)
|
||||
if (token) {
|
||||
url.searchParams.set('token', token)
|
||||
}
|
||||
|
||||
eventSource = new EventSource(url.toString())
|
||||
|
||||
eventSource.onopen = () => {
|
||||
isConnected.value = true
|
||||
onConnectionChange.value?.(true)
|
||||
|
||||
// 清除重连定时器
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data)
|
||||
handleMessage(message)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('Music WebSocket error:', error)
|
||||
isConnected.value = false
|
||||
onConnectionChange.value?.(false)
|
||||
|
||||
// 自动重连
|
||||
scheduleReconnect()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket connection:', error)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理消息
|
||||
const handleMessage = (message: any) => {
|
||||
const { type, data } = message
|
||||
|
||||
switch (type) {
|
||||
case 'connection_established':
|
||||
connectionId.value = data.connectionId
|
||||
|
||||
break
|
||||
|
||||
case 'music_state_update':
|
||||
onStateUpdate.value?.(data)
|
||||
break
|
||||
|
||||
case 'song_change':
|
||||
onSongChange.value?.(data)
|
||||
break
|
||||
|
||||
case 'playlist_update':
|
||||
onPlaylistUpdate.value?.(data.playlist)
|
||||
break
|
||||
|
||||
case 'heartbeat':
|
||||
lastHeartbeat.value = data.timestamp
|
||||
break
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
const disconnect = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
if (heartbeatTimer) {
|
||||
clearTimeout(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
|
||||
isConnected.value = false
|
||||
connectionId.value = null
|
||||
onConnectionChange.value?.(false)
|
||||
}
|
||||
|
||||
// 计划重连
|
||||
const scheduleReconnect = () => {
|
||||
if (reconnectTimer) return
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connect()
|
||||
}, 5000) // 5秒后重连
|
||||
}
|
||||
|
||||
// 发送音乐状态更新
|
||||
const sendStateUpdate = async (state: Partial<MusicState>) => {
|
||||
try {
|
||||
await $fetch('/api/music/state', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'state_update',
|
||||
data: state
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to send music state update:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送歌曲切换通知
|
||||
const sendSongChange = async (songInfo: Partial<SongInfo>) => {
|
||||
try {
|
||||
await $fetch('/api/music/state', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'song_change',
|
||||
data: songInfo
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to send song change:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送播放位置更新
|
||||
const sendPositionUpdate = async (position: number, duration: number, songId?: number) => {
|
||||
try {
|
||||
await $fetch('/api/music/state', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'position_update',
|
||||
data: {
|
||||
songId,
|
||||
position,
|
||||
duration,
|
||||
isPlaying: true
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to send position update:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置事件监听器
|
||||
const setStateUpdateListener = (callback: (state: MusicState) => void) => {
|
||||
onStateUpdate.value = callback
|
||||
}
|
||||
|
||||
const setSongChangeListener = (callback: (songInfo: SongInfo) => void) => {
|
||||
onSongChange.value = callback
|
||||
}
|
||||
|
||||
const setPlaylistUpdateListener = (callback: (playlist: any[]) => void) => {
|
||||
onPlaylistUpdate.value = callback
|
||||
}
|
||||
|
||||
const setConnectionChangeListener = (callback: (connected: boolean) => void) => {
|
||||
onConnectionChange.value = callback
|
||||
}
|
||||
|
||||
// 生命周期管理
|
||||
onMounted(() => {
|
||||
// 启动心跳检测
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (isConnected.value && lastHeartbeat.value > 0) {
|
||||
const timeSinceLastHeartbeat = Date.now() - lastHeartbeat.value
|
||||
if (timeSinceLastHeartbeat > 60000) {
|
||||
// 60秒没有心跳
|
||||
console.warn('Music WebSocket heartbeat timeout, reconnecting...')
|
||||
disconnect()
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}, 30000) // 每30秒检查一次
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
isConnected: readonly(isConnected),
|
||||
connectionId: readonly(connectionId),
|
||||
connect,
|
||||
disconnect,
|
||||
sendStateUpdate,
|
||||
sendSongChange,
|
||||
sendPositionUpdate,
|
||||
setStateUpdateListener,
|
||||
setSongChangeListener,
|
||||
setPlaylistUpdateListener,
|
||||
setConnectionChangeListener
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuth } from './useAuth'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import type { Notification, NotificationSettings } from '~/types'
|
||||
|
||||
export const useNotifications = () => {
|
||||
const { getAuthConfig, isAuthenticated } = useAuth()
|
||||
const { composableErrors } = useLocale()
|
||||
const action = (key: keyof typeof composableErrors.value.actions) => composableErrors.value.actions[key]
|
||||
const loginRequired = (key: keyof typeof composableErrors.value.actions) => composableErrors.value.loginRequired(action(key))
|
||||
const failed = (key: keyof typeof composableErrors.value.actions) => composableErrors.value.failed(action(key))
|
||||
|
||||
const notifications = ref<Notification[]>([])
|
||||
const unreadCount = ref(0)
|
||||
const settings = ref<NotificationSettings | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 分页相关状态
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalPages = ref(0)
|
||||
const totalCount = ref(0)
|
||||
const currentFilter = ref<'all' | 'unread'>('all')
|
||||
const currentSearch = ref('')
|
||||
const hasNextPage = computed(() => currentPage.value < totalPages.value)
|
||||
const hasPrevPage = computed(() => currentPage.value > 1)
|
||||
const isPaginationLoading = ref(false)
|
||||
let fetchSequence = 0
|
||||
|
||||
const fetchNotifications = async (
|
||||
page?: number,
|
||||
limit?: number,
|
||||
filter: 'all' | 'unread' = currentFilter.value,
|
||||
search: string = currentSearch.value
|
||||
) => {
|
||||
const requestId = ++fetchSequence
|
||||
if (!isAuthenticated.value) {
|
||||
notifications.value = []
|
||||
unreadCount.value = 0
|
||||
resetPagination()
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是分页请求(非初始加载),显示分页加载状态
|
||||
if (page && page !== 1) {
|
||||
isPaginationLoading.value = true
|
||||
} else {
|
||||
loading.value = true
|
||||
}
|
||||
error.value = ''
|
||||
|
||||
// 使用传入的参数或当前状态
|
||||
const requestPage = page || currentPage.value
|
||||
const requestLimit = limit || pageSize.value
|
||||
const requestFilter = filter === 'unread' ? 'unread' : 'all'
|
||||
const requestSearch = search.trim().slice(0, 100)
|
||||
currentFilter.value = requestFilter
|
||||
currentSearch.value = requestSearch
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/notifications', {
|
||||
query: {
|
||||
page: requestPage,
|
||||
limit: requestLimit,
|
||||
filter: requestFilter,
|
||||
search: requestSearch || undefined
|
||||
},
|
||||
...authConfig
|
||||
})
|
||||
|
||||
if (data && requestId === fetchSequence) {
|
||||
notifications.value = data.notifications || []
|
||||
unreadCount.value = data.unreadCount || 0
|
||||
|
||||
// 更新分页信息
|
||||
if (data.pagination) {
|
||||
currentPage.value = data.pagination.currentPage
|
||||
totalPages.value = data.pagination.totalPages
|
||||
totalCount.value = data.pagination.totalCount
|
||||
pageSize.value = data.pagination.limit
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (requestId !== fetchSequence) return
|
||||
const errorHandler = useErrorHandler()
|
||||
if (await errorHandler.checkAndHandleFetchError(err)) {
|
||||
return
|
||||
}
|
||||
console.error('获取通知错误:', err)
|
||||
notifications.value = []
|
||||
unreadCount.value = 0
|
||||
resetPagination()
|
||||
} finally {
|
||||
if (requestId === fetchSequence) {
|
||||
loading.value = false
|
||||
isPaginationLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重置分页状态
|
||||
const resetPagination = () => {
|
||||
currentPage.value = 1
|
||||
totalPages.value = 0
|
||||
totalCount.value = 0
|
||||
}
|
||||
|
||||
// 分页导航方法
|
||||
const goToPage = async (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value && page !== currentPage.value) {
|
||||
await fetchNotifications(page, pageSize.value)
|
||||
}
|
||||
}
|
||||
|
||||
const nextPage = async () => {
|
||||
if (hasNextPage.value) {
|
||||
await goToPage(currentPage.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const prevPage = async () => {
|
||||
if (hasPrevPage.value) {
|
||||
await goToPage(currentPage.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const changePageSize = async (newSize: number) => {
|
||||
if (newSize !== pageSize.value) {
|
||||
pageSize.value = newSize
|
||||
// 重新计算当前页,确保不超出范围
|
||||
const newTotalPages = Math.ceil(totalCount.value / newSize)
|
||||
const newCurrentPage = Math.min(currentPage.value, newTotalPages || 1)
|
||||
await fetchNotifications(newCurrentPage, newSize)
|
||||
}
|
||||
}
|
||||
|
||||
const changeFilter = async (filter: 'all' | 'unread') => {
|
||||
if (filter === currentFilter.value) return
|
||||
currentPage.value = 1
|
||||
await fetchNotifications(1, pageSize.value, filter)
|
||||
}
|
||||
|
||||
const changeSearch = async (search: string) => {
|
||||
const normalizedSearch = search.trim().slice(0, 100)
|
||||
if (normalizedSearch === currentSearch.value) return
|
||||
currentPage.value = 1
|
||||
await fetchNotifications(1, pageSize.value, currentFilter.value, normalizedSearch)
|
||||
}
|
||||
|
||||
const fetchNotificationSettings = async () => {
|
||||
if (!isAuthenticated.value) {
|
||||
settings.value = null
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/notifications/settings', {
|
||||
...authConfig
|
||||
})
|
||||
|
||||
if (data) {
|
||||
settings.value = data
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorHandler = useErrorHandler()
|
||||
if (await errorHandler.checkAndHandleFetchError(err)) {
|
||||
return
|
||||
}
|
||||
console.error('获取通知设置错误:', err)
|
||||
settings.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateNotificationSettings = async (newSettings: Partial<NotificationSettings>) => {
|
||||
if (!isAuthenticated.value) {
|
||||
error.value = loginRequired('updateNotificationSettings')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/notifications/settings', {
|
||||
method: 'POST',
|
||||
body: newSettings,
|
||||
...authConfig
|
||||
})
|
||||
|
||||
if (data) {
|
||||
settings.value = data
|
||||
return data
|
||||
}
|
||||
return null
|
||||
} catch (err: any) {
|
||||
const errorHandler = useErrorHandler()
|
||||
if (await errorHandler.checkAndHandleFetchError(err)) {
|
||||
return null
|
||||
}
|
||||
error.value = err.message || failed('updateNotificationSettings')
|
||||
console.error('更新通知设置错误:', err)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 标记通知为已读
|
||||
const markAsRead = async (notificationId: number) => {
|
||||
if (!isAuthenticated.value) {
|
||||
error.value = loginRequired('markNotification')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch(`/api/notifications/${notificationId}/read`, {
|
||||
method: 'POST',
|
||||
...authConfig
|
||||
})
|
||||
|
||||
const index = notifications.value.findIndex((n: Notification) => n.id === notificationId)
|
||||
if (index !== -1) {
|
||||
notifications.value[index].read = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
}
|
||||
|
||||
if (currentFilter.value === 'unread') {
|
||||
const targetPage = notifications.value.length <= 1 && currentPage.value > 1
|
||||
? currentPage.value - 1
|
||||
: currentPage.value
|
||||
await fetchNotifications(targetPage, pageSize.value)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
const errorHandler = useErrorHandler()
|
||||
if (await errorHandler.checkAndHandleFetchError(err)) {
|
||||
return null
|
||||
}
|
||||
error.value = err.message || failed('markNotification')
|
||||
console.error('标记通知错误:', err)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 标记所有通知为已读
|
||||
const markAllAsRead = async () => {
|
||||
if (!isAuthenticated.value) {
|
||||
error.value = loginRequired('markAllNotifications')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/notifications/read-all', {
|
||||
method: 'POST',
|
||||
...authConfig
|
||||
})
|
||||
|
||||
if (data) {
|
||||
notifications.value.forEach((n) => (n.read = true))
|
||||
unreadCount.value = 0
|
||||
if (currentFilter.value === 'unread') {
|
||||
notifications.value = []
|
||||
resetPagination()
|
||||
}
|
||||
return data
|
||||
}
|
||||
return null
|
||||
} catch (err: any) {
|
||||
const errorHandler = useErrorHandler()
|
||||
if (await errorHandler.checkAndHandleFetchError(err)) {
|
||||
return null
|
||||
}
|
||||
error.value = err.message || failed('markAllNotifications')
|
||||
console.error('标记所有通知错误:', err)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除通知
|
||||
const deleteNotification = async (notificationId: number) => {
|
||||
if (!isAuthenticated.value) {
|
||||
error.value = loginRequired('deleteNotification')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
await $fetch(`/api/notifications/${notificationId}`, {
|
||||
method: 'DELETE',
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 更新本地通知列表
|
||||
const index = notifications.value.findIndex((n: Notification) => n.id === notificationId)
|
||||
if (index !== -1) {
|
||||
if (!notifications.value[index].read) {
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
}
|
||||
notifications.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 删除后重新获取当前页数据,以补充后续通知
|
||||
if (notifications.value.length === 0 && currentPage.value > 1) {
|
||||
// 如果当前页数据已被清空且不是第一页,则跳转到上一页
|
||||
await goToPage(currentPage.value - 1)
|
||||
} else {
|
||||
// 否则重新加载当前页(如果有下一页数据会自动补充上来)
|
||||
await fetchNotifications(currentPage.value, pageSize.value)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err: any) {
|
||||
// 检查是否为401错误
|
||||
const errorHandler = useErrorHandler()
|
||||
if (await errorHandler.checkAndHandleFetchError(err)) {
|
||||
return null
|
||||
}
|
||||
|
||||
error.value = err.message || failed('deleteNotification')
|
||||
console.error('删除通知错误:', err)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清空所有通知
|
||||
const clearAllNotifications = async () => {
|
||||
if (!isAuthenticated.value) {
|
||||
error.value = loginRequired('clearNotifications')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
await $fetch('/api/notifications/clear-all', {
|
||||
method: 'DELETE',
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 清空本地通知列表
|
||||
notifications.value = []
|
||||
unreadCount.value = 0
|
||||
resetPagination()
|
||||
|
||||
return true
|
||||
} catch (err: any) {
|
||||
// 检查是否为401错误
|
||||
const errorHandler = useErrorHandler()
|
||||
if (await errorHandler.checkAndHandleFetchError(err)) {
|
||||
return null
|
||||
}
|
||||
|
||||
error.value = err.message || failed('clearNotifications')
|
||||
console.error('清空通知错误:', err)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 计算未读通知数量
|
||||
const computedUnreadCount = computed(() => unreadCount.value)
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount: computedUnreadCount,
|
||||
settings,
|
||||
loading,
|
||||
error,
|
||||
// 分页相关状态
|
||||
currentPage,
|
||||
pageSize,
|
||||
totalPages,
|
||||
totalCount,
|
||||
currentFilter,
|
||||
currentSearch,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
isPaginationLoading,
|
||||
// 原有方法
|
||||
fetchNotifications,
|
||||
fetchNotificationSettings,
|
||||
updateNotificationSettings,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
deleteNotification,
|
||||
clearAllNotifications,
|
||||
// 分页方法
|
||||
goToPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
changePageSize,
|
||||
changeFilter,
|
||||
changeSearch,
|
||||
resetPagination
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// 微信/QQ 内置浏览器账号密码登录后的绑定引导,仅保存在当前浏览器
|
||||
const PENDING_KEY = 'voicehub:oauth-bind-reminder-pending'
|
||||
const DISMISSED_KEY = 'voicehub:oauth-bind-reminder-dismissed'
|
||||
|
||||
const getBrowserStorage = () =>
|
||||
import.meta.client && typeof window !== 'undefined' ? window.localStorage : null
|
||||
|
||||
export const useOAuthBindReminder = () => {
|
||||
const markPasswordLogin = () => {
|
||||
getBrowserStorage()?.setItem(PENDING_KEY, '1')
|
||||
}
|
||||
|
||||
const hasPendingPasswordLogin = () => getBrowserStorage()?.getItem(PENDING_KEY) === '1'
|
||||
|
||||
const clearPending = () => {
|
||||
getBrowserStorage()?.removeItem(PENDING_KEY)
|
||||
}
|
||||
|
||||
const isDismissed = () => getBrowserStorage()?.getItem(DISMISSED_KEY) === '1'
|
||||
|
||||
const dismissForever = () => {
|
||||
const storage = getBrowserStorage()
|
||||
storage?.setItem(DISMISSED_KEY, '1')
|
||||
storage?.removeItem(PENDING_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
markPasswordLogin,
|
||||
hasPendingPasswordLogin,
|
||||
clearPending,
|
||||
isDismissed,
|
||||
dismissForever
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
export function usePasswordStrength(passwordRef: Ref<string>) {
|
||||
const { common } = useLocale()
|
||||
const strengthLevels = computed(() => common.value?.passwordStrengthLevels || {})
|
||||
|
||||
return computed(() => {
|
||||
if (!passwordRef.value) {
|
||||
return { width: '0%', colorClass: '', textColorClass: '', text: '' }
|
||||
}
|
||||
|
||||
let score = 0
|
||||
const value = passwordRef.value
|
||||
if (value.length >= 8) score++
|
||||
if (/[a-z]/.test(value)) score++
|
||||
if (/[A-Z]/.test(value)) score++
|
||||
if (/[0-9]/.test(value)) score++
|
||||
if (/[^A-Za-z0-9]/.test(value)) score++
|
||||
|
||||
const scorePercentage = (score / 5) * 100
|
||||
|
||||
if (score < 3) {
|
||||
return {
|
||||
width: `${scorePercentage || 10}%`,
|
||||
colorClass: 'bg-error',
|
||||
textColorClass: 'text-error',
|
||||
text: strengthLevels.value?.weak || ''
|
||||
}
|
||||
} else if (score < 4) {
|
||||
return {
|
||||
width: `${scorePercentage}%`,
|
||||
colorClass: 'bg-warning',
|
||||
textColorClass: 'text-warning',
|
||||
text: strengthLevels.value?.medium || ''
|
||||
}
|
||||
} else if (score < 5) {
|
||||
return {
|
||||
width: `${scorePercentage}%`,
|
||||
colorClass: 'bg-primary',
|
||||
textColorClass: 'text-primary',
|
||||
text: strengthLevels.value?.strong || ''
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
width: '100%',
|
||||
colorClass: 'bg-success',
|
||||
textColorClass: 'text-success',
|
||||
text: strengthLevels.value?.veryStrong || ''
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { computed } from 'vue'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
export const usePermissions = () => {
|
||||
const auth = useAuth()
|
||||
|
||||
// 检查用户是否可以访问后台
|
||||
const canAccessAdmin = computed(() => {
|
||||
if (!auth.user.value) return false
|
||||
const role = auth.user.value.role
|
||||
return ['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(role)
|
||||
})
|
||||
|
||||
// 检查用户是否可以访问指定页面
|
||||
const canAccessPage = (page: string): boolean => {
|
||||
if (!auth.user.value) return false
|
||||
|
||||
const role = auth.user.value.role
|
||||
|
||||
// 超级管理员可以访问所有页面
|
||||
if (role === 'SUPER_ADMIN') return true
|
||||
|
||||
// 管理员可以访问的页面
|
||||
if (role === 'ADMIN') {
|
||||
return ['overview', 'schedule', 'print', 'songs', 'users', 'grade-class', 'playtimes', 'request-times', 'semesters', 'data-analysis', 'card-codes', 'music-source'].includes(page)
|
||||
}
|
||||
|
||||
// 歌曲管理员可以访问的页面
|
||||
if (role === 'SONG_ADMIN') {
|
||||
return ['overview', 'schedule', 'print', 'songs', 'playtimes', 'request-times', 'semesters', 'data-analysis', 'card-codes'].includes(page)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取用户可访问的页面列表
|
||||
const getUserPages = computed(() => {
|
||||
if (!auth.user.value) return []
|
||||
|
||||
const role = auth.user.value.role
|
||||
|
||||
switch (role) {
|
||||
case 'SUPER_ADMIN':
|
||||
return [
|
||||
'overview',
|
||||
'schedule',
|
||||
'print',
|
||||
'songs',
|
||||
'users',
|
||||
'grade-class',
|
||||
'notifications',
|
||||
'smtp-config',
|
||||
'playtimes',
|
||||
'request-times',
|
||||
'semesters',
|
||||
'blacklist',
|
||||
'site-config',
|
||||
'database',
|
||||
'api-keys',
|
||||
'data-analysis',
|
||||
'music-source'
|
||||
]
|
||||
case 'ADMIN':
|
||||
return ['overview', 'schedule', 'print', 'songs', 'users', 'grade-class', 'playtimes', 'request-times', 'semesters', 'data-analysis', 'card-codes', 'music-source']
|
||||
case 'SONG_ADMIN':
|
||||
return ['overview', 'schedule', 'print', 'songs', 'playtimes', 'request-times', 'semesters', 'data-analysis', 'card-codes']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// 角色检查
|
||||
const isUser = computed(() => auth.user.value?.role === 'USER')
|
||||
const isSongAdmin = computed(() => auth.user.value?.role === 'SONG_ADMIN')
|
||||
const isAdmin = computed(() => auth.user.value?.role === 'ADMIN')
|
||||
const isSuperAdmin = computed(() => auth.user.value?.role === 'SUPER_ADMIN')
|
||||
|
||||
// 权限组合检查
|
||||
const isAdminOrAbove = computed(() => {
|
||||
const role = auth.user.value?.role
|
||||
return ['ADMIN', 'SUPER_ADMIN'].includes(role)
|
||||
})
|
||||
|
||||
const isSongAdminOrAbove = computed(() => {
|
||||
const role = auth.user.value?.role
|
||||
return ['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(role)
|
||||
})
|
||||
|
||||
// 检查是否可以管理用户角色
|
||||
const canManageUserRole = (targetRole: string): boolean => {
|
||||
if (!auth.user.value) return false
|
||||
|
||||
// 只有超级管理员可以分配角色
|
||||
if (auth.user.value.role === 'SUPER_ADMIN') {
|
||||
return ['USER', 'SONG_ADMIN', 'ADMIN'].includes(targetRole)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否可以打印排期
|
||||
const canPrintSchedule = computed(() => {
|
||||
if (!auth.user.value) return false
|
||||
const role = auth.user.value.role
|
||||
return ['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(role)
|
||||
})
|
||||
|
||||
return {
|
||||
// 页面访问权限
|
||||
canAccessAdmin,
|
||||
canAccessPage,
|
||||
getUserPages,
|
||||
|
||||
// 角色检查
|
||||
isUser,
|
||||
isSongAdmin,
|
||||
isAdmin,
|
||||
isSuperAdmin,
|
||||
isAdminOrAbove,
|
||||
isSongAdminOrAbove,
|
||||
|
||||
// 功能权限
|
||||
canPrintSchedule,
|
||||
|
||||
// 用户管理权限
|
||||
canManageUserRole
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 平台管理 Composable
|
||||
* 提供当前站点启用的音乐平台和排序顺序
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useSiteConfig } from './useSiteConfig'
|
||||
import { DEFAULT_PLATFORMS } from '~/utils/platforms'
|
||||
|
||||
const cache = {
|
||||
enabledPlatforms: ref<string[]>([...DEFAULT_PLATFORMS]),
|
||||
platformOrder: ref<string[]>([...DEFAULT_PLATFORMS]),
|
||||
loaded: ref(false),
|
||||
// 加载中的 promise,避免并发重复请求
|
||||
inflight: null as Promise<void> | null
|
||||
}
|
||||
|
||||
/** 安全解析平台数组,解析失败时回退默认值 */
|
||||
const parsePlatformArray = (value: unknown): string[] => {
|
||||
try {
|
||||
const arr = typeof value === 'string' ? JSON.parse(value) : value
|
||||
if (!Array.isArray(arr)) return [...DEFAULT_PLATFORMS]
|
||||
const valid = arr.filter((p: unknown) => (DEFAULT_PLATFORMS as readonly string[]).includes(p as string))
|
||||
return valid.length > 0 ? (valid as string[]) : [...DEFAULT_PLATFORMS]
|
||||
} catch {
|
||||
return [...DEFAULT_PLATFORMS]
|
||||
}
|
||||
}
|
||||
|
||||
export const usePlatformConfig = () => {
|
||||
const enabledPlatforms = cache.enabledPlatforms
|
||||
const platformOrder = cache.platformOrder
|
||||
const loaded = cache.loaded
|
||||
|
||||
/**
|
||||
* 加载平台配置
|
||||
* @param forceApi 强制走 API 请求(跳过 useSiteConfig 优化),供管理页保存后刷新使用
|
||||
*/
|
||||
const doLoadPlatformConfig = async (forceApi = false) => {
|
||||
// 优先复用 useSiteConfig 已加载的数据(/api/site-config 已含平台字段),避免重复请求
|
||||
if (!forceApi) {
|
||||
try {
|
||||
const { siteConfig, isLoaded } = useSiteConfig()
|
||||
if (isLoaded.value && siteConfig.value?.enabledPlatforms) {
|
||||
enabledPlatforms.value = parsePlatformArray(siteConfig.value.enabledPlatforms)
|
||||
platformOrder.value = parsePlatformArray(siteConfig.value.platformOrder)
|
||||
loaded.value = true
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// useSiteConfig 数据未就绪,回退到独立 API 请求
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await $fetch('/api/platform-config')
|
||||
enabledPlatforms.value = parsePlatformArray(res.enabledPlatforms)
|
||||
platformOrder.value = parsePlatformArray(res.platformOrder)
|
||||
loaded.value = true
|
||||
} catch {
|
||||
// SSR 阶段 $fetch 不可用,保持 loaded=false 让客户端重试
|
||||
if (import.meta.server) return
|
||||
enabledPlatforms.value = [...DEFAULT_PLATFORMS]
|
||||
platformOrder.value = [...DEFAULT_PLATFORMS]
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const loadPlatformConfig = async () => {
|
||||
if (import.meta.server) return
|
||||
if (loaded.value) return
|
||||
// 已有加载请求进行中则直接复用,避免并发重复请求
|
||||
if (cache.inflight) return cache.inflight
|
||||
cache.inflight = doLoadPlatformConfig().finally(() => {
|
||||
cache.inflight = null
|
||||
})
|
||||
await cache.inflight
|
||||
}
|
||||
|
||||
/** 重置加载状态并重新获取配置(跳过 useSiteConfig 优化,确保读到最新数据),供管理员保存后刷新使用 */
|
||||
const refreshPlatformConfig = async () => {
|
||||
if (import.meta.server) return
|
||||
loaded.value = false
|
||||
await doLoadPlatformConfig(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用的平台列表(按配置的排序顺序)
|
||||
*/
|
||||
const getAvailablePlatforms = (): string[] => {
|
||||
return platformOrder.value.filter((p) => enabledPlatforms.value.includes(p))
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断平台是否可用
|
||||
*/
|
||||
const isPlatformEnabled = (platform: string): boolean => {
|
||||
return enabledPlatforms.value.includes(platform)
|
||||
}
|
||||
|
||||
return {
|
||||
enabledPlatforms,
|
||||
platformOrder,
|
||||
loaded,
|
||||
loadPlatformConfig,
|
||||
refreshPlatformConfig,
|
||||
getAvailablePlatforms,
|
||||
isPlatformEnabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const useProgress = () => {
|
||||
const progress = ref(0)
|
||||
const message = ref('')
|
||||
const subMessage = ref('')
|
||||
const active = ref(false)
|
||||
const indeterminate = ref(false)
|
||||
const completed = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 进度百分比,确保在0-100范围内
|
||||
const percentage = computed(() => {
|
||||
return Math.min(100, Math.max(0, progress.value))
|
||||
})
|
||||
|
||||
// 启动进度条
|
||||
const start = (initialMessage = '处理中...') => {
|
||||
progress.value = 0
|
||||
message.value = initialMessage
|
||||
subMessage.value = ''
|
||||
active.value = true
|
||||
indeterminate.value = false
|
||||
completed.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
// 启动不确定进度条(不显示具体百分比,用于未知进度的操作)
|
||||
const startIndeterminate = (initialMessage = '处理中...') => {
|
||||
progress.value = 0
|
||||
message.value = initialMessage
|
||||
subMessage.value = ''
|
||||
active.value = true
|
||||
indeterminate.value = true
|
||||
completed.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
const update = (newProgress: number, newMessage?: string, newSubMessage?: string) => {
|
||||
progress.value = newProgress
|
||||
|
||||
if (newMessage !== undefined) {
|
||||
message.value = newMessage
|
||||
}
|
||||
|
||||
if (newSubMessage !== undefined) {
|
||||
subMessage.value = newSubMessage
|
||||
}
|
||||
|
||||
if (progress.value >= 100) {
|
||||
complete()
|
||||
}
|
||||
}
|
||||
|
||||
// 完成进度
|
||||
const complete = (finalMessage = '处理完成') => {
|
||||
progress.value = 100
|
||||
message.value = finalMessage
|
||||
active.value = true
|
||||
indeterminate.value = false
|
||||
completed.value = true
|
||||
|
||||
// 2秒后自动隐藏进度条
|
||||
setTimeout(() => {
|
||||
if (completed.value) {
|
||||
active.value = false
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 设置错误
|
||||
const setError = (errorMessage: string) => {
|
||||
error.value = errorMessage
|
||||
active.value = true
|
||||
indeterminate.value = false
|
||||
|
||||
// 3秒后自动隐藏错误
|
||||
setTimeout(() => {
|
||||
if (error.value) {
|
||||
active.value = false
|
||||
error.value = ''
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 重置进度条
|
||||
const reset = () => {
|
||||
progress.value = 0
|
||||
message.value = ''
|
||||
subMessage.value = ''
|
||||
active.value = false
|
||||
indeterminate.value = false
|
||||
completed.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
progress,
|
||||
percentage,
|
||||
message,
|
||||
subMessage,
|
||||
active,
|
||||
indeterminate,
|
||||
completed,
|
||||
error,
|
||||
start,
|
||||
startIndeterminate,
|
||||
update,
|
||||
complete,
|
||||
setError,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { useProgress } from './useProgress'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
export const useProgressEvents = () => {
|
||||
const {
|
||||
percentage,
|
||||
message,
|
||||
subMessage,
|
||||
active,
|
||||
indeterminate,
|
||||
completed,
|
||||
error,
|
||||
start,
|
||||
startIndeterminate,
|
||||
update,
|
||||
complete,
|
||||
setError,
|
||||
reset
|
||||
} = useProgress()
|
||||
|
||||
const progressId = ref('')
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
// 连接到进度事件流
|
||||
const connect = async (id: string) => {
|
||||
// 确保之前的连接已关闭
|
||||
disconnect()
|
||||
|
||||
progressId.value = id
|
||||
startIndeterminate('正在连接...')
|
||||
|
||||
try {
|
||||
// cookie 认证下不能把真实 JWT 暴露到 URL,保留旧 token 参数仅作兼容。
|
||||
const auth = useAuth()
|
||||
const token = auth.getToken()
|
||||
const params = new URLSearchParams({ id })
|
||||
if (token) {
|
||||
params.set('token', token)
|
||||
}
|
||||
eventSource = new EventSource('/api/progress/events?' + params.toString(), {
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
// 连接打开
|
||||
eventSource.onopen = () => {
|
||||
startIndeterminate('已连接,等待进度更新...')
|
||||
}
|
||||
|
||||
// 接收消息
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
if (data.connected) {
|
||||
startIndeterminate('已连接,等待进度更新...')
|
||||
return
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (data.completed) {
|
||||
complete(data.message || '操作完成')
|
||||
disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
if (data.progress !== undefined) {
|
||||
// 如果之前是不确定状态,切换到确定状态
|
||||
if (indeterminate.value) {
|
||||
start(data.message || '处理中...')
|
||||
}
|
||||
|
||||
update(
|
||||
data.progress,
|
||||
data.message !== undefined ? data.message : undefined,
|
||||
data.subMessage !== undefined ? data.subMessage : undefined
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('处理进度事件时出错:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 错误处理
|
||||
eventSource.onerror = (err) => {
|
||||
console.error('进度事件流错误:', err)
|
||||
setError('连接进度更新失败')
|
||||
disconnect()
|
||||
}
|
||||
|
||||
// 关闭事件
|
||||
eventSource.addEventListener('close', () => {
|
||||
disconnect()
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('创建EventSource时出错:', err)
|
||||
setError('无法连接到进度更新服务')
|
||||
}
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
const disconnect = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取新的进度ID
|
||||
const getProgressId = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/progress/id', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取进度ID失败')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
progressId.value = data.id
|
||||
return data.id
|
||||
} catch (err) {
|
||||
console.error('获取进度ID时出错:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
progressId,
|
||||
percentage,
|
||||
message,
|
||||
subMessage,
|
||||
active,
|
||||
indeterminate,
|
||||
completed,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
getProgressId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface PendingRequest {
|
||||
promise: Promise<any>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// 请求去重超时时间
|
||||
const REQUEST_DEDUP_TIMEOUT = 5000 // 5秒
|
||||
|
||||
export const useRequestDedup = () => {
|
||||
// 正在进行的请求(用于去重)
|
||||
const pendingRequests = new Map<string, PendingRequest>()
|
||||
|
||||
// 生成请求键
|
||||
const generateRequestKey = (type: string, params?: Record<string, any>): string => {
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return type
|
||||
}
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.map((key) => `${key}=${params[key]}`)
|
||||
.join('&')
|
||||
return `${type}?${sortedParams}`
|
||||
}
|
||||
|
||||
// 清理过期的请求
|
||||
const cleanupPendingRequests = (): void => {
|
||||
const now = Date.now()
|
||||
const keysToDelete: string[] = []
|
||||
|
||||
pendingRequests.forEach((request, key) => {
|
||||
if (now - request.timestamp > REQUEST_DEDUP_TIMEOUT) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
|
||||
keysToDelete.forEach((key) => pendingRequests.delete(key))
|
||||
}
|
||||
|
||||
// 带去重的请求函数
|
||||
const dedupedRequest = async <T>(
|
||||
type: string,
|
||||
requestFn: () => Promise<T>,
|
||||
params?: Record<string, any>
|
||||
): Promise<T> => {
|
||||
const requestKey = generateRequestKey(type, params)
|
||||
|
||||
// 清理过期的请求
|
||||
cleanupPendingRequests()
|
||||
|
||||
// 检查是否有相同的请求正在进行
|
||||
const pendingRequest = pendingRequests.get(requestKey)
|
||||
if (pendingRequest) {
|
||||
try {
|
||||
return await pendingRequest.promise
|
||||
} catch (error) {
|
||||
// 如果请求失败,移除pending状态
|
||||
pendingRequests.delete(requestKey)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新的请求
|
||||
const promise = requestFn()
|
||||
pendingRequests.set(requestKey, {
|
||||
promise,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
try {
|
||||
const data = await promise
|
||||
|
||||
// 移除pending状态
|
||||
pendingRequests.delete(requestKey)
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
// 请求失败时移除pending状态
|
||||
pendingRequests.delete(requestKey)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有pending请求
|
||||
const clearAllPendingRequests = (): void => {
|
||||
pendingRequests.clear()
|
||||
}
|
||||
|
||||
// 获取pending请求统计
|
||||
const getPendingStats = () => {
|
||||
return {
|
||||
totalPendingRequests: pendingRequests.size,
|
||||
pendingByType: {} as Record<string, number>
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 核心去重方法
|
||||
dedupedRequest,
|
||||
|
||||
// 管理方法
|
||||
clearAllPendingRequests,
|
||||
cleanupPendingRequests,
|
||||
|
||||
// 工具方法
|
||||
generateRequestKey,
|
||||
getPendingStats
|
||||
}
|
||||
}
|
||||
|
||||
// 全局去重实例
|
||||
let globalDedupInstance: ReturnType<typeof useRequestDedup> | null = null
|
||||
|
||||
export const getGlobalDedup = () => {
|
||||
if (!globalDedupInstance) {
|
||||
globalDedupInstance = useRequestDedup()
|
||||
}
|
||||
return globalDedupInstance
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
const formatLocaleString = (value: string, args: unknown[]) => {
|
||||
if (args.length === 0) return value
|
||||
return value.replace(/{(\d+)}/g, (match, index) =>
|
||||
args[Number(index)] !== undefined ? String(args[Number(index)]) : match
|
||||
)
|
||||
}
|
||||
|
||||
export const useSafeLocale = <T extends Record<string, any>>(source: T): T => {
|
||||
if (source == null || typeof source !== 'object') return source
|
||||
|
||||
return new Proxy(source, {
|
||||
get(target, prop) {
|
||||
const value = target[prop as keyof T]
|
||||
|
||||
if (typeof value === 'function') return value
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const formatter = (...args: unknown[]) => formatLocaleString(value, args)
|
||||
formatter.toString = () => value
|
||||
formatter.valueOf = () => value
|
||||
return formatter
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return useSafeLocale(value as Record<string, any>)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
}) as T
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getSyncedTimestamp } from '~/composables/useSyncedTime'
|
||||
|
||||
const STORAGE_KEY = 'voicehub_scroll_positions'
|
||||
/** 恢复等待上限:列表数据异步渲染后高度才达标,轮询至到位或超时放弃 */
|
||||
const RESTORE_MAX_WAIT_MS = 3000
|
||||
/** 认为已恢复到目标位置的容差 */
|
||||
const RESTORE_TOLERANCE_PX = 8
|
||||
/** 低于该值的保存位置不占用存储 */
|
||||
const MIN_SAVE_PX = 8
|
||||
/** 相邻保存最小间隔 */
|
||||
const SAVE_INTERVAL_MS = 300
|
||||
|
||||
type ScrollMap = Record<string, number>
|
||||
|
||||
const readMap = (): ScrollMap => {
|
||||
if (!import.meta.client) return {}
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 以重建方式写入,过滤掉当前键与低于阈值的历史记录 */
|
||||
const writeTop = (key: string, top: number): void => {
|
||||
if (!import.meta.client || !key) return
|
||||
try {
|
||||
const next: ScrollMap = {}
|
||||
for (const [k, v] of Object.entries(readMap())) {
|
||||
if (k !== key && v >= MIN_SAVE_PX) next[k] = v
|
||||
}
|
||||
if (top >= MIN_SAVE_PX) next[key] = Math.floor(top)
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
} catch {
|
||||
// 存储不可用(隐私模式等)时静默降级
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面滚动位置记忆:window 滚动距离按「路由 fullPath + 子键」存取。
|
||||
* 子键用于区分同一页面内部 Tab(如首页排期/歌曲/投稿/通知、控制台各面板),
|
||||
* 各 Tab 独立记忆并在切回时自动恢复;页面返回时等内容渲染到足够高度再定位,
|
||||
* 避免被异步加载打断
|
||||
*/
|
||||
export const useScrollMemory = (getSubKey?: () => string): void => {
|
||||
const route = useRoute()
|
||||
|
||||
let lastSavedAt = 0
|
||||
// 恢复期间程序化滚动会触发 scroll 事件,此标记避免把中间值当成用户位置写回
|
||||
let restoring = false
|
||||
/** 代次令牌:新恢复任务或卸载后使仍在进行的轮询失效 */
|
||||
let restoreToken = 0
|
||||
|
||||
const keyOf = (sub?: string): string => (sub ? `${route.fullPath}#${sub}` : route.fullPath)
|
||||
|
||||
const saveNow = (): void => {
|
||||
lastSavedAt = getSyncedTimestamp()
|
||||
writeTop(keyOf(getSubKey?.()), window.scrollY)
|
||||
}
|
||||
|
||||
const onScroll = (): void => {
|
||||
if (restoring) return
|
||||
if (getSyncedTimestamp() - lastSavedAt < SAVE_INTERVAL_MS) return
|
||||
saveNow()
|
||||
}
|
||||
|
||||
// 恢复等待期间尚未到达目标,保留原记录避免被中间值覆盖
|
||||
const safeSaveNow = (): void => {
|
||||
if (restoring) return
|
||||
saveNow()
|
||||
}
|
||||
|
||||
const startRestore = (key: string): void => {
|
||||
const target = readMap()[key]
|
||||
if (!target || target <= RESTORE_TOLERANCE_PX) return
|
||||
restoring = true
|
||||
const token = ++restoreToken
|
||||
const startedAt = getSyncedTimestamp()
|
||||
requestAnimationFrame(function tryRestore() {
|
||||
if (token !== restoreToken) return
|
||||
if (getSyncedTimestamp() - startedAt > RESTORE_MAX_WAIT_MS) {
|
||||
restoring = false
|
||||
return
|
||||
}
|
||||
const maxY = document.documentElement.scrollHeight - window.innerHeight
|
||||
if (maxY >= target - RESTORE_TOLERANCE_PX) {
|
||||
window.scrollTo(0, target)
|
||||
restoring = false
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(tryRestore)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startRestore(keyOf(getSubKey?.()))
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
window.addEventListener('beforeunload', safeSaveNow)
|
||||
})
|
||||
|
||||
if (getSubKey) {
|
||||
watch(getSubKey, (next, prev) => {
|
||||
// 切换瞬间视口位置仍属于旧 Tab,先归档;新 Tab 内容渲染后再找回归属位置
|
||||
writeTop(keyOf(prev), window.scrollY)
|
||||
startRestore(keyOf(next))
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
restoreToken++
|
||||
restoring = false
|
||||
safeSaveNow()
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('beforeunload', safeSaveNow)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import type { Semester } from '~/types'
|
||||
|
||||
// 全局事件总线用于学期更新通知
|
||||
const semesterUpdateEvent = ref(0)
|
||||
|
||||
// SessionStorage 键名常量
|
||||
const SELECTED_SEMESTER_KEY = 'voicehub_selected_semester'
|
||||
const AVAILABLE_SEMESTERS_KEY = 'voicehub_available_semesters'
|
||||
|
||||
// 持久化工具函数
|
||||
const persistenceUtils = {
|
||||
// 保存选中的学期
|
||||
saveSelectedSemester: (semester: string | null) => {
|
||||
try {
|
||||
if (semester) {
|
||||
sessionStorage.setItem(SELECTED_SEMESTER_KEY, semester)
|
||||
} else {
|
||||
sessionStorage.removeItem(SELECTED_SEMESTER_KEY)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('保存选中学期失败:', error)
|
||||
}
|
||||
},
|
||||
|
||||
// 获取选中的学期
|
||||
getSelectedSemester: (): string | null => {
|
||||
try {
|
||||
return sessionStorage.getItem(SELECTED_SEMESTER_KEY)
|
||||
} catch (error) {
|
||||
console.warn('获取选中学期失败:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// 保存可用学期列表
|
||||
saveAvailableSemesters: (semesters: string[]) => {
|
||||
try {
|
||||
sessionStorage.setItem(AVAILABLE_SEMESTERS_KEY, JSON.stringify(semesters))
|
||||
} catch (error) {
|
||||
console.warn('保存可用学期列表失败:', error)
|
||||
}
|
||||
},
|
||||
|
||||
// 获取可用学期列表
|
||||
getAvailableSemesters: (): string[] => {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(AVAILABLE_SEMESTERS_KEY)
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch (error) {
|
||||
console.warn('获取可用学期列表失败:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
// 清除所有持久化数据
|
||||
clearAll: () => {
|
||||
try {
|
||||
sessionStorage.removeItem(SELECTED_SEMESTER_KEY)
|
||||
sessionStorage.removeItem(AVAILABLE_SEMESTERS_KEY)
|
||||
} catch (error) {
|
||||
console.warn('清除持久化数据失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useSemesters() {
|
||||
const { composableErrors } = useLocale()
|
||||
const action = (key: keyof typeof composableErrors.value.actions) => composableErrors.value.actions[key]
|
||||
const loginRequired = (key: keyof typeof composableErrors.value.actions) => composableErrors.value.loginRequired(action(key))
|
||||
const failed = (key: keyof typeof composableErrors.value.actions) => composableErrors.value.failed(action(key))
|
||||
const semesters = ref<Semester[]>([])
|
||||
const currentSemester = ref<Semester | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 触发学期更新事件
|
||||
const triggerSemesterUpdate = () => {
|
||||
semesterUpdateEvent.value++
|
||||
}
|
||||
|
||||
// 获取学期列表(管理员专用)
|
||||
const fetchSemesters = async () => {
|
||||
const { isAuthenticated, getAuthConfig } = useAuth()
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
error.value = loginRequired('getSemesters')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const response = await fetch('/api/admin/semesters', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...authConfig
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || `${failed('getSemesters')}: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
semesters.value = data as Semester[]
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('getSemesters')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前活跃学期
|
||||
const fetchCurrentSemester = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/semesters/current')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(failed('getCurrentSemester'))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
currentSemester.value = data
|
||||
} catch (err: any) {
|
||||
console.error('获取当前学期失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建学期
|
||||
const createSemester = async (semesterData: { name: string; isActive?: boolean }) => {
|
||||
const { isAuthenticated, getAuthConfig } = useAuth()
|
||||
const payload = {
|
||||
...semesterData,
|
||||
name: semesterData.name.trim()
|
||||
}
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
error.value = loginRequired('createSemester')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const response = await fetch('/api/admin/semesters', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
...authConfig
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || failed('createSemester'))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 更新学期列表
|
||||
await fetchSemesters()
|
||||
|
||||
// 如果设置为活跃学期,也更新当前学期
|
||||
if (payload.isActive) {
|
||||
await fetchCurrentSemester()
|
||||
}
|
||||
|
||||
// 触发全局学期更新事件
|
||||
triggerSemesterUpdate()
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('createSemester')
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 设置活跃学期
|
||||
const setActiveSemester = async (semesterId: number) => {
|
||||
const { isAuthenticated, getAuthConfig } = useAuth()
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
error.value = loginRequired('setActiveSemester')
|
||||
return false
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const response = await fetch('/api/admin/semesters/set-active', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ semesterId }),
|
||||
...authConfig
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || failed('setActiveSemester'))
|
||||
}
|
||||
|
||||
// 更新学期列表
|
||||
await fetchSemesters()
|
||||
await fetchCurrentSemester()
|
||||
|
||||
// 触发全局学期更新事件
|
||||
triggerSemesterUpdate()
|
||||
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('setActiveSemester')
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新学期名称
|
||||
const updateSemester = async (semesterId: number, semesterData: { name: string }) => {
|
||||
const { isAuthenticated, getAuthConfig } = useAuth()
|
||||
const payload = {
|
||||
...semesterData,
|
||||
name: semesterData.name.trim()
|
||||
}
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
error.value = loginRequired('updateSemester')
|
||||
return false
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const response = await fetch(`/api/admin/semesters/${semesterId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
...authConfig
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || failed('updateSemester'))
|
||||
}
|
||||
|
||||
// 更新学期列表
|
||||
await fetchSemesters()
|
||||
|
||||
// 如果更新的是当前学期,需要刷新当前学期数据
|
||||
if (currentSemester.value?.id === semesterId) {
|
||||
await fetchCurrentSemester()
|
||||
}
|
||||
|
||||
// 触发全局学期更新事件
|
||||
triggerSemesterUpdate()
|
||||
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('updateSemester')
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除学期
|
||||
const deleteSemester = async (semesterId: number) => {
|
||||
const { isAuthenticated, getAuthConfig } = useAuth()
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
error.value = loginRequired('deleteSemester')
|
||||
return false
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const response = await fetch(`/api/admin/semesters/${semesterId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...authConfig
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || failed('deleteSemester'))
|
||||
}
|
||||
|
||||
// 更新学期列表
|
||||
await fetchSemesters()
|
||||
|
||||
// 触发全局学期更新事件
|
||||
triggerSemesterUpdate()
|
||||
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = err.message || failed('deleteSemester')
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有学期选项
|
||||
const fetchSemesterOptions = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await fetch('/api/semesters/options')
|
||||
if (!response.ok) {
|
||||
throw new Error(failed('getSemesterOptions'))
|
||||
}
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
semesters.value = data.data
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('获取学期选项失败:', err)
|
||||
error.value = err.message || failed('getSemesterOptions')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
semesters: readonly(semesters),
|
||||
currentSemester: readonly(currentSemester),
|
||||
loading: readonly(loading),
|
||||
error: readonly(error),
|
||||
semesterUpdateEvent: readonly(semesterUpdateEvent),
|
||||
fetchSemesters,
|
||||
fetchCurrentSemester,
|
||||
fetchSemesterOptions,
|
||||
createSemester,
|
||||
setActiveSemester,
|
||||
updateSemester,
|
||||
deleteSemester,
|
||||
triggerSemesterUpdate,
|
||||
// 导出持久化工具函数
|
||||
persistenceUtils
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { computed, ref, readonly } from 'vue'
|
||||
import { getAggregateOAuthLoginTypesOrDefault, getProviderDisplayName } from '~/utils/oauth'
|
||||
import { applyThemeConfig, useTheme } from '~/composables/useTheme'
|
||||
|
||||
const THEME_LOGO_SEPARATOR = '||'
|
||||
|
||||
// 兼容旧配置:没有分隔符时,地址只归入深色;浅色运行时回退到深色。
|
||||
export const splitThemeLogoUrl = (value) => {
|
||||
const normalized = typeof value === 'string' ? value.trim() : ''
|
||||
if (!normalized) return { dark: '', light: '' }
|
||||
|
||||
const separatorIndex = normalized.indexOf(THEME_LOGO_SEPARATOR)
|
||||
if (separatorIndex < 0) return { dark: normalized, light: '' }
|
||||
|
||||
const dark = normalized.slice(0, separatorIndex).trim()
|
||||
const light = normalized.slice(separatorIndex + THEME_LOGO_SEPARATOR.length).trim()
|
||||
return {
|
||||
dark: dark || light,
|
||||
light: light || dark
|
||||
}
|
||||
}
|
||||
|
||||
export const joinThemeLogoUrl = (dark, light) => {
|
||||
const normalizedDark = typeof dark === 'string' ? dark.trim() : ''
|
||||
const normalizedLight = typeof light === 'string' ? light.trim() : ''
|
||||
if (!normalizedDark && !normalizedLight) return ''
|
||||
if (!normalizedLight || normalizedLight === normalizedDark) return normalizedDark || normalizedLight
|
||||
return `${normalizedDark}||${normalizedLight}`
|
||||
}
|
||||
|
||||
const defaultSubmissionGuidelines = `1. 投稿时无需加入书名号
|
||||
2. 除DJ外,其他类型歌曲均接收(包括小语种)
|
||||
3. 禁止投递含有违规内容的歌曲
|
||||
4. 点播的歌曲将由管理员进行审核
|
||||
5. 审核通过后将安排在播放时段播出
|
||||
6. 提交即表明我已阅读投稿须知并已知该歌曲有概率无法播出
|
||||
7. 本系统仅提供音乐搜索和播放管理功能,不存储任何音乐文件。所有音乐内容均来自第三方音乐平台,版权归原平台及版权方所有。用户点歌时请确保遵守相关音乐平台的服务条款,尊重音乐作品版权。我们鼓励用户支持正版音乐,在官方平台购买和收听喜爱的音乐作品。
|
||||
8. 最终解释权归广播站所有`
|
||||
|
||||
// 站点配置状态
|
||||
const siteConfig = ref({
|
||||
siteTitle: '',
|
||||
siteLogoUrl: '',
|
||||
schoolLogoHomeUrl: '',
|
||||
schoolLogoPrintUrl: '',
|
||||
siteDescription: '',
|
||||
submissionGuidelines: '',
|
||||
icpNumber: '',
|
||||
gonganNumber: '',
|
||||
captchaEnabled: false,
|
||||
captchaProvider: 'graphic',
|
||||
turnstileSiteKey: '',
|
||||
enableSubmissionLimit: false,
|
||||
enableCardCodeRequests: false,
|
||||
requireCardCodeForRequests: false,
|
||||
enableCardCodeLimitBypass: false,
|
||||
githubOAuthEnabled: false,
|
||||
casdoorOAuthEnabled: false,
|
||||
googleOAuthEnabled: false,
|
||||
aggregateOAuthEnabled: false,
|
||||
aggregateOAuthLoginType: 'qq',
|
||||
customOAuthEnabled: false,
|
||||
customOAuthDisplayName: '',
|
||||
allowRegister: false,
|
||||
submissionNoteRequiresApproval: false,
|
||||
registerEmailRequired: false,
|
||||
registerRequiresGradeClass: false,
|
||||
defaultTheme: 'System',
|
||||
enabledThemes: JSON.stringify(['System', 'ClassicDark', 'ClassicLight', 'ModernLight'])
|
||||
})
|
||||
|
||||
const isLoaded = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const getImageDisplayUrl = (url) => {
|
||||
const normalizedUrl = typeof url === 'string' ? url.trim() : ''
|
||||
if (!normalizedUrl) return ''
|
||||
|
||||
// HTTP 图片通过同源代理加载,避免在 HTTPS 页面中被浏览器按混合内容拦截
|
||||
if (/^http:\/\//i.test(normalizedUrl)) {
|
||||
return `/api/proxy/image?url=${encodeURIComponent(normalizedUrl)}`
|
||||
}
|
||||
|
||||
return normalizedUrl
|
||||
}
|
||||
|
||||
export const useSiteConfig = () => {
|
||||
const { isDark } = useTheme()
|
||||
// 获取站点配置
|
||||
const fetchSiteConfig = async () => {
|
||||
if (isLoading.value) return
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch('/api/site-config')
|
||||
if (!response.ok) {
|
||||
throw new Error('获取站点配置失败')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
siteConfig.value = data
|
||||
try {
|
||||
applyThemeConfig(
|
||||
data.defaultTheme,
|
||||
typeof data.enabledThemes === 'string' ? JSON.parse(data.enabledThemes) : data.enabledThemes
|
||||
)
|
||||
} catch {
|
||||
applyThemeConfig('System', null)
|
||||
}
|
||||
isLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error('获取站点配置失败:', error)
|
||||
|
||||
// 使用默认配置
|
||||
siteConfig.value = {
|
||||
siteTitle: '校园广播站点歌系统',
|
||||
siteLogoUrl: '/favicon.ico',
|
||||
schoolLogoHomeUrl: '',
|
||||
schoolLogoPrintUrl: '',
|
||||
siteDescription: '校园广播站点歌系统 - 让你的声音被听见',
|
||||
submissionGuidelines: defaultSubmissionGuidelines,
|
||||
icpNumber: '',
|
||||
gonganNumber: '',
|
||||
enableReplayRequests: false,
|
||||
enableSubmissionRestriction: false,
|
||||
enableCollaborativeSubmission: true,
|
||||
enableSubmissionRemarks: false,
|
||||
allowOAuthRegistration: false,
|
||||
captchaEnabled: false,
|
||||
captchaProvider: 'graphic',
|
||||
turnstileSiteKey: '',
|
||||
enableSubmissionLimit: false,
|
||||
enableCardCodeRequests: false,
|
||||
requireCardCodeForRequests: false,
|
||||
enableCardCodeLimitBypass: false,
|
||||
githubOAuthEnabled: false,
|
||||
casdoorOAuthEnabled: false,
|
||||
googleOAuthEnabled: false,
|
||||
aggregateOAuthEnabled: false,
|
||||
aggregateOAuthLoginType: 'qq',
|
||||
customOAuthEnabled: false,
|
||||
customOAuthDisplayName: '',
|
||||
allowRegister: false,
|
||||
submissionNoteRequiresApproval: false,
|
||||
registerEmailRequired: false,
|
||||
registerRequiresGradeClass: false,
|
||||
defaultTheme: 'System',
|
||||
enabledThemes: JSON.stringify(['System', 'ClassicDark', 'ClassicLight', 'ModernLight'])
|
||||
}
|
||||
isLoaded.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const siteTitle = computed(() => siteConfig.value.siteTitle || '校园广播站点歌系统')
|
||||
const logoUrl = computed(() => siteConfig.value.siteLogoUrl || '/favicon.ico')
|
||||
const schoolLogoHomeUrl = computed(() => {
|
||||
const logos = splitThemeLogoUrl(siteConfig.value.schoolLogoHomeUrl)
|
||||
return (isDark.value ? logos.dark : logos.light) || logos.dark || logos.light || ''
|
||||
})
|
||||
const schoolLogoPrintUrl = computed(() => siteConfig.value.schoolLogoPrintUrl || '')
|
||||
const schoolLogoHomeDisplayUrl = computed(() => getImageDisplayUrl(schoolLogoHomeUrl.value))
|
||||
const schoolLogoPrintDisplayUrl = computed(() => getImageDisplayUrl(schoolLogoPrintUrl.value))
|
||||
const description = computed(
|
||||
() => siteConfig.value.siteDescription || '校园广播站点歌系统 - 让你的声音被听见'
|
||||
)
|
||||
const guidelines = computed(
|
||||
() => siteConfig.value.submissionGuidelines || defaultSubmissionGuidelines
|
||||
)
|
||||
const icp = computed(() => siteConfig.value.icpNumber || '')
|
||||
const gonganNumber = computed(() => siteConfig.value.gonganNumber || '')
|
||||
const showBeianIcon = computed(() => siteConfig.value.showBeianIcon || false)
|
||||
const enableReplayRequests = computed(() => siteConfig.value.enableReplayRequests || false)
|
||||
const enableSubmissionRestriction = computed(
|
||||
() => siteConfig.value.enableSubmissionRestriction === true
|
||||
)
|
||||
const enableCollaborativeSubmission = computed(
|
||||
() => siteConfig.value.enableCollaborativeSubmission !== false
|
||||
)
|
||||
const enableSubmissionRemarks = computed(() => siteConfig.value.enableSubmissionRemarks === true)
|
||||
const enableSubmissionLimit = computed(() => siteConfig.value.enableSubmissionLimit === true)
|
||||
const enableCardCodeRequests = computed(() => siteConfig.value.enableCardCodeRequests === true)
|
||||
const requireCardCodeForRequests = computed(
|
||||
() =>
|
||||
enableCardCodeRequests.value && siteConfig.value.requireCardCodeForRequests === true
|
||||
)
|
||||
const enableCardCodeLimitBypass = computed(
|
||||
() => siteConfig.value.enableCardCodeLimitBypass === true
|
||||
)
|
||||
const allowOAuthRegistration = computed(() => siteConfig.value.allowOAuthRegistration === true)
|
||||
const allowRegister = computed(() => siteConfig.value.allowRegister === true)
|
||||
const registerEmailRequired = computed(() => siteConfig.value.registerEmailRequired === true)
|
||||
const registerRequiresGradeClass = computed(
|
||||
() => siteConfig.value.registerRequiresGradeClass === true
|
||||
)
|
||||
const submissionNoteRequiresApproval = computed(
|
||||
() => siteConfig.value.submissionNoteRequiresApproval === true
|
||||
)
|
||||
const captchaEnabled = computed(() => siteConfig.value.captchaEnabled === true)
|
||||
const captchaProvider = computed(() => siteConfig.value.captchaProvider || 'graphic')
|
||||
const turnstileSiteKey = computed(() => siteConfig.value.turnstileSiteKey || '')
|
||||
const smtpEnabled = computed(() => !!siteConfig.value.smtpEnabled)
|
||||
const oauth = computed(() => ({
|
||||
github: !!siteConfig.value.githubOAuthEnabled,
|
||||
casdoor: !!siteConfig.value.casdoorOAuthEnabled,
|
||||
google: !!siteConfig.value.googleOAuthEnabled,
|
||||
aggregate: !!siteConfig.value.aggregateOAuthEnabled,
|
||||
oauth2: !!siteConfig.value.customOAuthEnabled
|
||||
}))
|
||||
|
||||
const oauthProviders = computed(() => {
|
||||
const providers = []
|
||||
if (siteConfig.value.githubOAuthEnabled) {
|
||||
providers.push({ key: 'github', name: 'GitHub' })
|
||||
}
|
||||
if (siteConfig.value.casdoorOAuthEnabled) {
|
||||
providers.push({ key: 'casdoor', name: 'Casdoor' })
|
||||
}
|
||||
if (siteConfig.value.googleOAuthEnabled) {
|
||||
providers.push({ key: 'google', name: 'Google' })
|
||||
}
|
||||
if (siteConfig.value.aggregateOAuthEnabled) {
|
||||
const enabledLoginTypes = getAggregateOAuthLoginTypesOrDefault(
|
||||
siteConfig.value.aggregateOAuthLoginType
|
||||
)
|
||||
enabledLoginTypes.forEach((loginType) => {
|
||||
providers.push({
|
||||
key: `aggregate:${loginType}`,
|
||||
routeProvider: 'aggregate',
|
||||
loginType,
|
||||
name: getProviderDisplayName(`aggregate:${loginType}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
if (siteConfig.value.customOAuthEnabled) {
|
||||
providers.push({
|
||||
key: 'oauth2',
|
||||
name: siteConfig.value.customOAuthDisplayName || '第三方 OAuth'
|
||||
})
|
||||
}
|
||||
return providers
|
||||
})
|
||||
|
||||
// 初始化配置(仅在客户端执行)
|
||||
const initSiteConfig = async () => {
|
||||
if (typeof window !== 'undefined' && !isLoaded.value) {
|
||||
await fetchSiteConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新配置
|
||||
const refreshSiteConfig = async () => {
|
||||
isLoaded.value = false
|
||||
await fetchSiteConfig()
|
||||
}
|
||||
|
||||
return {
|
||||
siteConfig: readonly(siteConfig),
|
||||
isLoaded: readonly(isLoaded),
|
||||
isLoading: readonly(isLoading),
|
||||
siteTitle,
|
||||
logoUrl,
|
||||
schoolLogoHomeUrl,
|
||||
schoolLogoPrintUrl,
|
||||
schoolLogoHomeDisplayUrl,
|
||||
schoolLogoPrintDisplayUrl,
|
||||
description,
|
||||
guidelines,
|
||||
icp,
|
||||
gonganNumber,
|
||||
showBeianIcon,
|
||||
enableReplayRequests,
|
||||
enableSubmissionRestriction,
|
||||
enableCollaborativeSubmission,
|
||||
enableSubmissionRemarks,
|
||||
enableSubmissionLimit,
|
||||
enableCardCodeRequests,
|
||||
requireCardCodeForRequests,
|
||||
enableCardCodeLimitBypass,
|
||||
allowOAuthRegistration,
|
||||
allowRegister,
|
||||
submissionNoteRequiresApproval,
|
||||
registerEmailRequired,
|
||||
registerRequiresGradeClass,
|
||||
captchaEnabled,
|
||||
captchaProvider,
|
||||
turnstileSiteKey,
|
||||
smtpEnabled,
|
||||
oauth,
|
||||
oauthProviders,
|
||||
fetchSiteConfig,
|
||||
initSiteConfig,
|
||||
refreshSiteConfig
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getMusicUrl } from '~/utils/musicUrl'
|
||||
import { isBilibiliSong } from '~/utils/bilibiliSource'
|
||||
import { useAudioPlayer, type PlayableSong } from './useAudioPlayer'
|
||||
import { useToast } from './useToast'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import type { Song } from '~/types'
|
||||
|
||||
export const useSongPlayer = () => {
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const { showToast } = useToast()
|
||||
const { audioPlayer: audioPlayerLocale } = useLocale()
|
||||
|
||||
// 判断歌曲是否可入队(有自定义链接、B站视频或平台ID)
|
||||
const isPlayableSong = (s: Song | PlayableSong) => {
|
||||
return Boolean(
|
||||
(s.playUrl && s.playUrl.trim()) ||
|
||||
isBilibiliSong(s) ||
|
||||
(s.musicPlatform && s.musicId)
|
||||
)
|
||||
}
|
||||
|
||||
// 根据队列来源构建播放队列,返回队列与当前歌曲索引;来源为空时退化为单曲队列
|
||||
const buildQueue = (song: PlayableSong, queueSource?: (Song | PlayableSong)[]) => {
|
||||
const queue = (queueSource || []).filter(isPlayableSong) as PlayableSong[]
|
||||
const index = queue.findIndex((s) => String(s.id) === String(song.id))
|
||||
if (index === -1) {
|
||||
return { queue: [song, ...queue], index: 0 }
|
||||
}
|
||||
return { queue, index }
|
||||
}
|
||||
|
||||
const playSong = async (song: Song | PlayableSong, queueSource?: (Song | PlayableSong)[]) => {
|
||||
// 如果是当前选中的歌曲
|
||||
if (audioPlayer.isCurrentSong(song.id)) {
|
||||
// 如果正在播放,则暂停
|
||||
if (audioPlayer.getPlayingStatus().value) {
|
||||
audioPlayer.pauseSong()
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是当前歌曲但暂停了,则恢复播放
|
||||
const currentGlobalSong = audioPlayer.getCurrentSong().value
|
||||
if (currentGlobalSong && (currentGlobalSong.musicUrl || isBilibiliSong(currentGlobalSong))) {
|
||||
// 恢复播放时同步刷新播放队列
|
||||
const { queue, index } = buildQueue(currentGlobalSong, queueSource)
|
||||
audioPlayer.playSong(currentGlobalSong, queue, index)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let url = null
|
||||
|
||||
// 如果有手动填入的 playUrl,优先使用它(统一处理所有平台)
|
||||
if (song.playUrl && song.playUrl.trim()) {
|
||||
url = song.playUrl.trim()
|
||||
} else if (!isBilibiliSong(song)) {
|
||||
// 如果不是哔哩哔哩歌曲且没有手动 playUrl,通过 API 获取 URL
|
||||
url = await getMusicUrl(song.musicPlatform, song.musicId, song.playUrl, {
|
||||
musicInfo: {
|
||||
name: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album || undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
// 如果是哔哩哔哩歌曲且没有手动 playUrl,url 保持为 null,播放器会处理
|
||||
|
||||
const playableSong: PlayableSong = {
|
||||
...song,
|
||||
musicUrl: url
|
||||
}
|
||||
|
||||
const { queue, index } = buildQueue(playableSong, queueSource)
|
||||
audioPlayer.playSong(playableSong, queue, index)
|
||||
} catch (error: any) {
|
||||
console.error('播放失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
showToast(audioPlayerLocale.value.playFailed(errorMessage), 'error')
|
||||
|
||||
// 即使获取 URL 失败,也应该调用 playSong 以触发播放器的错误处理和弹窗逻辑
|
||||
const playableSong: PlayableSong = {
|
||||
...song,
|
||||
musicUrl: null
|
||||
}
|
||||
const { queue, index } = buildQueue(playableSong, queueSource)
|
||||
audioPlayer.playSong(playableSong, queue, index)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
playSong
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,817 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuth } from './useAuth'
|
||||
import { extractErrorCode, useServerErrors } from './useLocaleText'
|
||||
import { getGlobalDedup } from './useRequestDedup'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import type { PlayTime, Schedule, Song } from '~/types'
|
||||
|
||||
export const useSongs = () => {
|
||||
const { isAuthenticated, user, getAuthConfig } = useAuth()
|
||||
const { songs: songsLocale } = useLocale()
|
||||
const { localize: localizeServerError } = useServerErrors()
|
||||
const dedup = getGlobalDedup()
|
||||
|
||||
const songs = ref<Song[]>([])
|
||||
const publicSchedules = ref<Schedule[]>([])
|
||||
const publicSongs = ref<Song[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const notification = ref({ show: false, message: '', type: '' })
|
||||
const playTimes = ref<PlayTime[]>([])
|
||||
const playTimeEnabled = ref(false)
|
||||
const songCount = ref(0)
|
||||
const requestFormLocale = computed(() => songsLocale.value?.requestForm)
|
||||
const actionLocale = computed(() => songsLocale.value.actions)
|
||||
|
||||
let songsRequestVersion = 0
|
||||
let publicSchedulesRequestVersion = 0
|
||||
|
||||
// 显示通知
|
||||
const showNotification = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
||||
// 使用全局通知
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(message, type)
|
||||
} else {
|
||||
// 备用方案
|
||||
notification.value = {
|
||||
show: true,
|
||||
message,
|
||||
type
|
||||
}
|
||||
|
||||
// 3秒后自动关闭
|
||||
setTimeout(() => {
|
||||
notification.value.show = false
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取播放时段列表
|
||||
const fetchPlayTimes = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/play-times')
|
||||
playTimeEnabled.value = response.enabled
|
||||
|
||||
// 确保类型兼容性
|
||||
if (response.playTimes && Array.isArray(response.playTimes)) {
|
||||
playTimes.value = response.playTimes.map((pt) => ({
|
||||
id: pt.id,
|
||||
name: pt.name,
|
||||
startTime: pt.startTime || undefined,
|
||||
endTime: pt.endTime || undefined,
|
||||
enabled: pt.enabled,
|
||||
description: pt.description || undefined
|
||||
}))
|
||||
} else {
|
||||
playTimes.value = []
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '获取播放时段失败'
|
||||
return { enabled: false, playTimes: [] }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取歌曲列表
|
||||
const fetchSongs = async (silent = false, semester?: string, forceRefresh = false) => {
|
||||
const requestVersion = ++songsRequestVersion
|
||||
if (!silent) {
|
||||
loading.value = true
|
||||
}
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const requestUserId = user.value?.id ?? null
|
||||
const requestParams = {
|
||||
semester: semester || '',
|
||||
audience: requestUserId ? `user:${requestUserId}` : 'guest',
|
||||
role: user.value?.role || ''
|
||||
}
|
||||
const request = async () => {
|
||||
// 构建URL参数
|
||||
const params = new URLSearchParams()
|
||||
if (semester) {
|
||||
params.append('semester', semester)
|
||||
}
|
||||
const url = `/api/songs${params.toString() ? '?' + params.toString() : ''}`
|
||||
|
||||
// API请求
|
||||
return await $fetch(url, {
|
||||
...getAuthConfig()
|
||||
})
|
||||
}
|
||||
const response = forceRefresh
|
||||
? await request()
|
||||
: await dedup.dedupedRequest('songs', request, requestParams)
|
||||
|
||||
if (
|
||||
requestVersion !== songsRequestVersion ||
|
||||
(user.value?.id ?? null) !== requestUserId ||
|
||||
(user.value?.role ?? '') !== (requestParams.role || '')
|
||||
)
|
||||
return
|
||||
|
||||
// 正确解析API返回的数据结构
|
||||
if (response && response.success && response.data && Array.isArray(response.data.songs)) {
|
||||
songs.value = response.data.songs as Song[]
|
||||
} else {
|
||||
songs.value = []
|
||||
console.warn('API返回的数据格式不正确:', response)
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (requestVersion === songsRequestVersion) {
|
||||
error.value = err.message || '获取歌曲列表失败'
|
||||
}
|
||||
} finally {
|
||||
if (!silent && requestVersion === songsRequestVersion) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 静默刷新歌曲列表 - 不显示加载状态
|
||||
const refreshSongsSilent = async () => {
|
||||
return fetchSongs(true)
|
||||
}
|
||||
|
||||
// 从排期数据中提取歌曲信息
|
||||
const extractSongsFromSchedules = (schedules: Schedule[]): Song[] => {
|
||||
const songsMap = new Map<string, Song>()
|
||||
|
||||
schedules.forEach((schedule) => {
|
||||
if (schedule.song) {
|
||||
const songId = String(schedule.song.id)
|
||||
if (!songsMap.has(songId)) {
|
||||
// 将排期中的歌曲信息转换为完整的Song对象
|
||||
const completeSong: Song = {
|
||||
id: schedule.song.id,
|
||||
title: schedule.song.title,
|
||||
artist: schedule.song.artist,
|
||||
requester: schedule.song.requester,
|
||||
requesterGrade: schedule.song.requesterGrade || null,
|
||||
requesterClass: schedule.song.requesterClass || null,
|
||||
requesterId: 0, // 默认值,公共API不提供这个信息
|
||||
voteCount: schedule.song.voteCount,
|
||||
played: schedule.song.played || false,
|
||||
playedAt: schedule.song.playedAt || null,
|
||||
semester: schedule.song.semester || null,
|
||||
requestedAt: schedule.song.requestedAt || new Date().toISOString(),
|
||||
cover: schedule.song.cover || null,
|
||||
musicPlatform: schedule.song.musicPlatform || null,
|
||||
musicId: schedule.song.musicId || null,
|
||||
durationSeconds: schedule.song.durationSeconds || null,
|
||||
usedCardCode: schedule.song.usedCardCode === true,
|
||||
hasSubmissionNote: schedule.song.hasSubmissionNote === true,
|
||||
submissionNote: schedule.song.submissionNote || null,
|
||||
submissionNotePublic: schedule.song.submissionNotePublic === true
|
||||
}
|
||||
songsMap.set(songId, completeSong)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(songsMap.values())
|
||||
}
|
||||
|
||||
// 获取公共排期(无需登录)
|
||||
const fetchPublicSchedules = async (silent = false, semester?: string, forceRefresh = false) => {
|
||||
const requestVersion = ++publicSchedulesRequestVersion
|
||||
if (!silent) {
|
||||
loading.value = true
|
||||
}
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const requestUserId = user.value?.id ?? null
|
||||
const requestParams = {
|
||||
semester: semester || '',
|
||||
audience: requestUserId ? `user:${requestUserId}` : 'guest',
|
||||
role: user.value?.role || ''
|
||||
}
|
||||
const request = async () => {
|
||||
// 构建URL参数
|
||||
const params = new URLSearchParams()
|
||||
if (semester) {
|
||||
params.append('semester', semester)
|
||||
}
|
||||
const url = `/api/songs/public${params.toString() ? '?' + params.toString() : ''}`
|
||||
|
||||
const response = await $fetch(url, {
|
||||
...getAuthConfig()
|
||||
})
|
||||
return response
|
||||
}
|
||||
const data = forceRefresh
|
||||
? await request()
|
||||
: await dedup.dedupedRequest('public-schedules', request, requestParams)
|
||||
|
||||
if (
|
||||
requestVersion !== publicSchedulesRequestVersion ||
|
||||
(user.value?.id ?? null) !== requestUserId ||
|
||||
(user.value?.role ?? '') !== (requestParams.role || '')
|
||||
)
|
||||
return
|
||||
|
||||
// 确保每个排期的歌曲都有played属性,并处理null/undefined转换
|
||||
const processedData = data.map((schedule: any) => {
|
||||
// 处理歌曲属性
|
||||
if (schedule.song && schedule.song.played === undefined) {
|
||||
schedule.song.played = false
|
||||
}
|
||||
|
||||
// 处理播放时间属性
|
||||
let processedPlayTime = null
|
||||
if (schedule.playTime) {
|
||||
processedPlayTime = {
|
||||
...schedule.playTime,
|
||||
startTime: schedule.playTime.startTime || undefined,
|
||||
endTime: schedule.playTime.endTime || undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 返回符合Schedule类型的对象
|
||||
return {
|
||||
...schedule,
|
||||
playTime: processedPlayTime
|
||||
} as Schedule
|
||||
})
|
||||
|
||||
publicSchedules.value = processedData
|
||||
|
||||
// 直接从排期数据中提取歌曲信息,避免重复请求
|
||||
publicSongs.value = extractSongsFromSchedules(processedData)
|
||||
} catch (err: any) {
|
||||
if (requestVersion === publicSchedulesRequestVersion) {
|
||||
error.value = err.message || '获取排期失败'
|
||||
}
|
||||
} finally {
|
||||
if (!silent && requestVersion === publicSchedulesRequestVersion) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 静默刷新公共排期 - 不显示加载状态
|
||||
const refreshSchedulesSilent = async () => {
|
||||
return fetchPublicSchedules(true)
|
||||
}
|
||||
|
||||
// 请求歌曲
|
||||
const requestSong = async (songData: {
|
||||
title: string
|
||||
artist: string
|
||||
preferredPlayTimeId?: number | null
|
||||
cover?: string | null
|
||||
musicPlatform?: string | null
|
||||
musicId?: string | null
|
||||
submissionNote?: string | null
|
||||
submissionNotePublic?: boolean
|
||||
cardCode?: string | null
|
||||
}) => {
|
||||
if (!isAuthenticated.value) {
|
||||
showNotification(actionLocale.value.loginRequest, 'error')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/songs/request', {
|
||||
method: 'POST',
|
||||
body: songData,
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 绕过去重,从数据库重新校准写入后的列表
|
||||
await fetchSongs(true, undefined, true)
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
const errorMsg = localizeServerError(err) || '点歌失败'
|
||||
// 如果是重复投稿错误,只显示通知而不设置全局错误
|
||||
if (errorMsg.includes('已经在列表中') || errorMsg.includes('不能重复投稿')) {
|
||||
showNotification(errorMsg, 'info')
|
||||
} else {
|
||||
error.value = errorMsg
|
||||
showNotification(errorMsg, 'error')
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 投票
|
||||
const voteSong = async (songId: number | { id: number; unvote?: boolean }) => {
|
||||
if (!isAuthenticated.value) {
|
||||
showNotification(actionLocale.value.loginVote, 'error')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
// 处理传入的可能是对象的情况
|
||||
const actualSongId = typeof songId === 'object' ? songId.id : songId
|
||||
const isUnvote = typeof songId === 'object' && songId.unvote === true
|
||||
|
||||
try {
|
||||
// 检查是否已经投过票(只在正常投票时检查)
|
||||
const targetSong = songs.value.find((s) => s.id === actualSongId)
|
||||
|
||||
if (!isUnvote && targetSong && targetSong.voted) {
|
||||
showNotification(actionLocale.value.alreadyVoted, 'info')
|
||||
loading.value = false
|
||||
return null
|
||||
}
|
||||
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
try {
|
||||
const data = await $fetch('/api/songs/vote', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
songId: actualSongId,
|
||||
unvote: isUnvote // 添加取消投票参数
|
||||
},
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 立即更新本地歌曲的投票状态
|
||||
if (targetSong) {
|
||||
if (isUnvote) {
|
||||
// 取消投票
|
||||
targetSong.voted = false
|
||||
targetSong.voteCount = Math.max(0, (targetSong.voteCount || 1) - 1)
|
||||
showNotification(actionLocale.value.voteCancelled(targetSong.title), 'success')
|
||||
} else {
|
||||
// 正常投票
|
||||
targetSong.voted = true
|
||||
targetSong.voteCount = (targetSong.voteCount || 0) + 1
|
||||
showNotification(actionLocale.value.voteSucceeded(targetSong.title), 'success')
|
||||
}
|
||||
}
|
||||
|
||||
await fetchSongs(true, undefined, true)
|
||||
|
||||
return data
|
||||
} catch (fetchErr: any) {
|
||||
// 处理API返回的错误
|
||||
const errorMsg = fetchErr.data?.message || fetchErr.message || '投票失败'
|
||||
|
||||
// 如果是已投票错误,只显示通知,不抛出异常
|
||||
if (errorMsg.includes('已经为这首歌投过票')) {
|
||||
showNotification(actionLocale.value.alreadyVoted, 'info')
|
||||
|
||||
// 如果API返回已投票错误,也更新本地状态
|
||||
if (targetSong && !targetSong.voted) {
|
||||
targetSong.voted = true
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 其他错误则抛出
|
||||
throw fetchErr
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.data?.message || err.message || '投票失败'
|
||||
error.value = errorMsg
|
||||
showNotification(errorMsg, 'error')
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 撤回歌曲(只能撤回自己的投稿)
|
||||
const withdrawSong = async (songId: number) => {
|
||||
if (!isAuthenticated.value) {
|
||||
showNotification(actionLocale.value.loginWithdraw, 'error')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 查找歌曲信息用于通知
|
||||
const targetSong = songs.value.find((s) => s.id === songId)
|
||||
const songTitle = targetSong ? targetSong.title : actionLocale.value.fallbackSongTitle
|
||||
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/songs/withdraw', {
|
||||
method: 'POST',
|
||||
body: { songId },
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 绕过去重,从数据库重新校准写入后的列表
|
||||
await fetchSongs(true, undefined, true)
|
||||
|
||||
// 显示成功通知
|
||||
let message = ''
|
||||
if (data.action === 'leave') {
|
||||
message = actionLocale.value.leaveCollaborationSucceeded(songTitle)
|
||||
} else if (data.message) {
|
||||
// 如果后端返回了特定消息,优先使用(除了撤回投稿的情况,我们要保留带标题的格式)
|
||||
message = data.message
|
||||
} else {
|
||||
message = data.quotaReturned
|
||||
? actionLocale.value.withdrawSucceededQuota(songTitle)
|
||||
: actionLocale.value.withdrawSucceeded(songTitle)
|
||||
}
|
||||
|
||||
if (data.action !== 'leave') {
|
||||
message = data.quotaReturned
|
||||
? actionLocale.value.withdrawSucceededQuota(songTitle)
|
||||
: actionLocale.value.withdrawSucceeded(songTitle)
|
||||
}
|
||||
|
||||
showNotification(message, 'success')
|
||||
return data
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.data?.message || err.message || '撤回歌曲失败'
|
||||
error.value = errorMsg
|
||||
showNotification(errorMsg, 'error')
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除歌曲(管理员专用)
|
||||
const deleteSong = async (songId: number) => {
|
||||
if (!isAuthenticated.value) {
|
||||
showNotification(actionLocale.value.loginDelete, 'error')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/admin/songs/delete', {
|
||||
method: 'POST',
|
||||
body: { songId },
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 绕过去重,从数据库重新校准写入后的列表
|
||||
await fetchSongs(true, undefined, true)
|
||||
|
||||
showNotification(actionLocale.value.deleteSucceeded, 'success')
|
||||
return data
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.data?.message || err.message || '删除歌曲失败'
|
||||
error.value = errorMsg
|
||||
showNotification(errorMsg, 'error')
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 标记歌曲为已播放(管理员专用)
|
||||
const markPlayed = async (songId: number) => {
|
||||
if (!isAuthenticated.value) {
|
||||
showNotification(actionLocale.value.loginMarkPlayed, 'error')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/admin/songs/mark-played', {
|
||||
method: 'POST',
|
||||
body: { songId },
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 绕过去重,从数据库重新校准写入后的列表
|
||||
await fetchSongs(true, undefined, true)
|
||||
|
||||
showNotification(actionLocale.value.markPlayedSucceeded, 'success')
|
||||
return data
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.data?.message || err.message || '标记歌曲失败'
|
||||
error.value = errorMsg
|
||||
showNotification(errorMsg, 'error')
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 撤回歌曲已播放状态(管理员专用)
|
||||
const unmarkPlayed = async (songId: number) => {
|
||||
if (!isAuthenticated.value) {
|
||||
showNotification(actionLocale.value.loginUnmarkPlayed, 'error')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 使用认证配置
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
const data = await $fetch('/api/admin/songs/mark-played', {
|
||||
method: 'POST',
|
||||
body: { songId, unmark: true },
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 绕过去重,从数据库重新校准写入后的列表
|
||||
await fetchSongs(true, undefined, true)
|
||||
|
||||
showNotification(actionLocale.value.unmarkPlayedSucceeded, 'success')
|
||||
return data
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.data?.message || err.message || '撤回歌曲已播放状态失败'
|
||||
error.value = errorMsg
|
||||
showNotification(errorMsg, 'error')
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 申请重播
|
||||
const requestReplay = async (
|
||||
songId: number,
|
||||
options: {
|
||||
preferredPlayTimeId?: number | null
|
||||
submissionNote?: string | null
|
||||
submissionNotePublic?: boolean
|
||||
} = {}
|
||||
) => {
|
||||
if (!isAuthenticated.value) {
|
||||
showNotification(actionLocale.value.loginRequestReplay, 'error')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const data = await $fetch('/api/songs/replay', {
|
||||
method: 'POST',
|
||||
body: { songId, ...options },
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 更新本地数据状态
|
||||
const songIndex = songs.value.findIndex((s) => s.id === songId)
|
||||
if (songIndex !== -1) {
|
||||
songs.value[songIndex].replayRequested = true
|
||||
}
|
||||
|
||||
await fetchSongs(true, undefined, true)
|
||||
|
||||
showNotification(actionLocale.value.replaySucceeded, 'success')
|
||||
return data
|
||||
} catch (err: any) {
|
||||
// 按错误码本地化展示,重复申请降级为提示
|
||||
if (extractErrorCode(err) === 'SONG_REPLAY_ALREADY_REQUESTED') {
|
||||
showNotification(actionLocale.value.replayAlreadyRequested, 'info')
|
||||
} else {
|
||||
showNotification(localizeServerError(err) || '申请重播失败', 'error')
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回重播申请
|
||||
* @param songId 歌曲ID
|
||||
*/
|
||||
const withdrawReplay = async (songId: number) => {
|
||||
const { user } = useAuth()
|
||||
if (!user.value) {
|
||||
showNotification(actionLocale.value.loginCancelReplay, 'error')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const data = await $fetch('/api/songs/replay', {
|
||||
method: 'DELETE',
|
||||
body: { songId },
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 更新本地数据状态
|
||||
const songIndex = songs.value.findIndex((s) => s.id === songId)
|
||||
if (songIndex !== -1) {
|
||||
songs.value[songIndex].replayRequested = false
|
||||
}
|
||||
|
||||
await fetchSongs(true, undefined, true)
|
||||
|
||||
showNotification(actionLocale.value.replayCancelled, 'success')
|
||||
return data
|
||||
} catch (err: any) {
|
||||
showNotification(localizeServerError(err) || '取消重播申请失败', 'error')
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 按热度排序的歌曲
|
||||
const songsByPopularity = computed(() => {
|
||||
return [...songs.value].sort((a, b) => b.voteCount - a.voteCount)
|
||||
})
|
||||
|
||||
// 按创建时间排序的歌曲
|
||||
const songsByDate = computed(() => {
|
||||
return [...songs.value].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
})
|
||||
|
||||
// 已播放的歌曲
|
||||
const playedSongs = computed(() => {
|
||||
return songs.value.filter((song) => song.played)
|
||||
})
|
||||
|
||||
// 未播放的歌曲
|
||||
const unplayedSongs = computed(() => {
|
||||
return songs.value.filter((song) => !song.played)
|
||||
})
|
||||
|
||||
// 我的投稿歌曲
|
||||
const mySongs = computed(() => {
|
||||
if (!user.value) return []
|
||||
return songs.value.filter((song) => song.requesterId === user.value?.id)
|
||||
})
|
||||
|
||||
// 我的重播申请歌曲
|
||||
const myReplaySongs = computed(() => {
|
||||
if (!user.value) return []
|
||||
return songs.value.filter((song) => song.replayRequested)
|
||||
})
|
||||
|
||||
// 所有可见的歌曲(登录用户看到的 + 公共歌曲)
|
||||
const visibleSongs = computed(() => {
|
||||
return isAuthenticated.value ? songs.value : publicSongs.value
|
||||
})
|
||||
|
||||
const clearPrivateSongs = () => {
|
||||
songsRequestVersion++
|
||||
songs.value = []
|
||||
}
|
||||
|
||||
const clearPublicSongs = () => {
|
||||
publicSchedulesRequestVersion++
|
||||
publicSongs.value = []
|
||||
publicSchedules.value = []
|
||||
}
|
||||
|
||||
// 根据播放时间段过滤歌曲排期
|
||||
const filterSchedulesByPlayTime = (schedules: Schedule[], playTimeId: number | null) => {
|
||||
if (playTimeId === null) {
|
||||
return schedules.filter((s) => s.playTimeId === null)
|
||||
}
|
||||
return schedules.filter((s) => s.playTimeId === playTimeId)
|
||||
}
|
||||
|
||||
// 获取播放时间名称
|
||||
const getPlayTimeName = (playTimeId: number | null) => {
|
||||
if (playTimeId === null) {
|
||||
return '未指定时段'
|
||||
}
|
||||
|
||||
const foundTime = playTimes.value.find((pt) => pt.id === playTimeId)
|
||||
return foundTime ? foundTime.name : '未知时段'
|
||||
}
|
||||
|
||||
// 格式化播放时间显示
|
||||
const formatPlayTimeDisplay = (playTime: PlayTime | null) => {
|
||||
if (!playTime) {
|
||||
return '全天'
|
||||
}
|
||||
|
||||
let displayText = playTime.name
|
||||
|
||||
// 如果有开始和结束时间,添加时间信息
|
||||
if (playTime.startTime && playTime.endTime) {
|
||||
displayText += ` (${playTime.startTime}-${playTime.endTime})`
|
||||
}
|
||||
// 如果只有开始时间
|
||||
else if (playTime.startTime) {
|
||||
displayText += ` (${playTime.startTime}起)`
|
||||
}
|
||||
// 如果只有结束时间
|
||||
else if (playTime.endTime) {
|
||||
displayText += ` (至${playTime.endTime})`
|
||||
}
|
||||
|
||||
return displayText
|
||||
}
|
||||
|
||||
// 获取歌曲总数
|
||||
const fetchSongCount = async (forceRefresh = false) => {
|
||||
try {
|
||||
const request = async () => {
|
||||
const response = await $fetch('/api/songs/count')
|
||||
return response
|
||||
}
|
||||
const response = forceRefresh
|
||||
? await request()
|
||||
: await dedup.dedupedRequest('song-count', request)
|
||||
|
||||
// 正确解析API返回的数据结构
|
||||
if (response && typeof response.count === 'number') {
|
||||
songCount.value = response.count
|
||||
return response.count
|
||||
} else {
|
||||
console.warn('歌曲总数API返回的数据格式不正确:', response)
|
||||
songCount.value = 0
|
||||
return 0
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('获取歌曲总数失败:', err)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化加载
|
||||
const initialize = async () => {
|
||||
await fetchPlayTimes()
|
||||
if (isAuthenticated.value) {
|
||||
await fetchSongs()
|
||||
} else {
|
||||
await fetchPublicSchedules()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
songs,
|
||||
publicSongs,
|
||||
publicSchedules,
|
||||
visibleSongs,
|
||||
songCount,
|
||||
loading,
|
||||
error,
|
||||
notification,
|
||||
playTimes,
|
||||
playTimeEnabled,
|
||||
showNotification,
|
||||
fetchSongs,
|
||||
fetchPublicSchedules,
|
||||
fetchPlayTimes,
|
||||
fetchSongCount,
|
||||
clearPrivateSongs,
|
||||
clearPublicSongs,
|
||||
refreshSongsSilent,
|
||||
refreshSchedulesSilent,
|
||||
requestSong,
|
||||
voteSong,
|
||||
withdrawSong,
|
||||
deleteSong,
|
||||
markPlayed,
|
||||
unmarkPlayed,
|
||||
requestReplay,
|
||||
withdrawReplay,
|
||||
filterSchedulesByPlayTime,
|
||||
getPlayTimeName,
|
||||
formatPlayTimeDisplay,
|
||||
extractSongsFromSchedules,
|
||||
initialize,
|
||||
songsByPopularity,
|
||||
songsByDate,
|
||||
playedSongs,
|
||||
unplayedSongs,
|
||||
mySongs,
|
||||
myReplaySongs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// 全局状态,保存本地时间与服务器时间的差值
|
||||
// offset = serverTime - localTime
|
||||
// 仅在客户端有效,服务端默认为0且不进行同步
|
||||
let timeOffset = 0
|
||||
let hasSynced = false
|
||||
|
||||
/**
|
||||
* 获取当前修正后的时间戳 (毫秒)
|
||||
* 可以在任何地方(包括非 Vue 组件环境)安全调用
|
||||
*/
|
||||
export const getSyncedTimestamp = (): number => {
|
||||
return Date.now() + timeOffset
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前修正后的 Date 对象
|
||||
*/
|
||||
export const getSyncedDate = (): Date => {
|
||||
return new Date(getSyncedTimestamp())
|
||||
}
|
||||
|
||||
export function useSyncedTime() {
|
||||
/**
|
||||
* 同步服务器时间
|
||||
* 直接调用本站后端提供的 API(/api/sys/time),返回服务器本机时间。
|
||||
*/
|
||||
const syncTime = async () => {
|
||||
// 服务端本身的时间就是服务器时间,不需要同步
|
||||
if (import.meta.server) {
|
||||
hasSynced = true
|
||||
return
|
||||
}
|
||||
|
||||
if (hasSynced) return
|
||||
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
// 请求我们自己的后端以获取标准时间戳,避免 CORS
|
||||
const data = await $fetch<{ timestamp: number }>('/api/sys/time')
|
||||
|
||||
if (data?.timestamp) {
|
||||
const endTime = Date.now()
|
||||
// 估算单向传输延迟
|
||||
const latency = (endTime - startTime) / 2
|
||||
const serverTime = data.timestamp + latency
|
||||
|
||||
timeOffset = serverTime - endTime
|
||||
hasSynced = true
|
||||
|
||||
console.log(`[TimeSync] 时间同步完成,与服务器时间相差 ${timeOffset}ms`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TimeSync] 时间同步失败,将使用本地时间:', error)
|
||||
hasSynced = true // 避免反复重试
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get offset() { return timeOffset },
|
||||
get hasSynced() { return hasSynced },
|
||||
syncTime,
|
||||
now: getSyncedTimestamp,
|
||||
nowDate: getSyncedDate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
|
||||
/** 主题类型('System' = 跟随系统,'ClassicDark' = 经典深色,'ClassicLight' = 经典浅色,'ModernLight' = 现代浅色)*/
|
||||
export type Theme = 'System' | 'ClassicDark' | 'ClassicLight' | 'ModernLight'
|
||||
|
||||
/** 可用主题列表(System 置顶) */
|
||||
export const THEMES: Theme[] = ['System', 'ClassicDark', 'ClassicLight', 'ModernLight'] // [跟随系统, 经典深色, 经典浅色, 现代浅色]
|
||||
|
||||
/** 跟随系统时按系统配色偏好解析为经典深色/经典浅色 */
|
||||
function resolveSystemTheme(): Theme {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'ClassicDark' : 'ClassicLight'
|
||||
}
|
||||
|
||||
let current: Ref<Theme> | null = null // 实际生效主题(始终为具体主题)
|
||||
let selected: Ref<Theme> | null = null // 用户选择主题(可为 System)
|
||||
let systemQuery: MediaQueryList | null = null
|
||||
let systemChangeHandler: ((event: MediaQueryListEvent) => void) | null = null
|
||||
const enabledThemes = ref<Theme[]>([...THEMES])
|
||||
let siteDefault: Theme = 'System'
|
||||
|
||||
function applyTheme(t: Theme) {
|
||||
current!.value = t
|
||||
document.documentElement.setAttribute('data-theme', t)
|
||||
}
|
||||
|
||||
/** 注册系统配色偏好监听:系统切换时自动应用对应经典主题 */
|
||||
function watchSystemTheme() {
|
||||
if (systemQuery) return
|
||||
systemQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
systemChangeHandler = () => applyTheme(resolveSystemTheme())
|
||||
systemQuery.addEventListener('change', systemChangeHandler)
|
||||
}
|
||||
|
||||
function unwatchSystemTheme() {
|
||||
if (systemQuery && systemChangeHandler) {
|
||||
systemQuery.removeEventListener('change', systemChangeHandler)
|
||||
}
|
||||
systemQuery = null
|
||||
systemChangeHandler = null
|
||||
}
|
||||
|
||||
/** 读取本地保存的主题,读取失败返回 null */
|
||||
function readSavedTheme(): Theme | null {
|
||||
try {
|
||||
return localStorage.getItem('voicehub-theme') as Theme | null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局主题状态引用。
|
||||
* 在组件 composable 中使用,服务端返回初始值但不订阅。
|
||||
*/
|
||||
export function useTheme() {
|
||||
// SSR 环境返回只读 stub,避免模块级单例在 Node 进程中跨请求共享可变状态
|
||||
if (import.meta.server) {
|
||||
const currentTheme = computed(() => 'ClassicDark' as Theme)
|
||||
const selectedTheme = computed(() => 'ClassicDark' as Theme)
|
||||
const isDark = computed(() => true)
|
||||
return {
|
||||
currentTheme,
|
||||
selectedTheme,
|
||||
isDark,
|
||||
themes: computed(() => enabledThemes.value),
|
||||
setTheme: () => {},
|
||||
toggleTheme: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
const saved: Theme | null = readSavedTheme()
|
||||
// 用户保存的主题被禁用时回退到站点默认;回退结果不写回 localStorage,避免把系统代选固化为用户偏好
|
||||
const chosen: Theme = (THEMES.includes(saved as Theme) && enabledThemes.value.includes(saved as Theme) ? saved : null) ?? siteDefault
|
||||
|
||||
selected = ref<Theme>(chosen)
|
||||
current = ref<Theme>(chosen === 'System' ? resolveSystemTheme() : chosen)
|
||||
document.documentElement.setAttribute('data-theme', current.value)
|
||||
if (chosen === 'System') watchSystemTheme()
|
||||
}
|
||||
|
||||
const theme = current!
|
||||
const chosen = selected!
|
||||
|
||||
const currentTheme = computed(() => theme.value)
|
||||
const selectedTheme = computed(() => chosen.value)
|
||||
const isDark = computed(() => theme.value === 'ClassicDark')
|
||||
|
||||
const setTheme = (t: Theme) => {
|
||||
if (!enabledThemes.value.includes(t)) return
|
||||
chosen.value = t
|
||||
if (t === 'System') {
|
||||
watchSystemTheme()
|
||||
applyTheme(resolveSystemTheme())
|
||||
} else {
|
||||
unwatchSystemTheme()
|
||||
applyTheme(t)
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('voicehub-theme', t)
|
||||
} catch {
|
||||
/* localStorage 写入失败(如配额满或被禁用),静默忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换主题:按顺序循环切换 */
|
||||
const toggleTheme = () => {
|
||||
const availableThemes = enabledThemes.value
|
||||
const nextIndex = (availableThemes.indexOf(chosen.value) + 1) % availableThemes.length
|
||||
setTheme(availableThemes[nextIndex]!)
|
||||
}
|
||||
|
||||
return {
|
||||
currentTheme,
|
||||
selectedTheme,
|
||||
isDark,
|
||||
themes: enabledThemes,
|
||||
setTheme,
|
||||
toggleTheme
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用服务端主题策略;用户已保存的主题优先于站点默认主题。 */
|
||||
export function applyThemeConfig(defaultTheme: unknown, configuredThemes: unknown) {
|
||||
if (import.meta.server) return
|
||||
if (!current || !selected) {
|
||||
console.warn('applyThemeConfig: useTheme 尚未初始化,主题配置暂不生效')
|
||||
return
|
||||
}
|
||||
const nextEnabled = Array.isArray(configuredThemes)
|
||||
? THEMES.filter((theme) => configuredThemes.includes(theme))
|
||||
: [...THEMES]
|
||||
enabledThemes.value = nextEnabled.length > 0 ? nextEnabled : [...THEMES]
|
||||
if (enabledThemes.value.includes('System') && (!enabledThemes.value.includes('ClassicDark') || !enabledThemes.value.includes('ClassicLight'))) {
|
||||
enabledThemes.value = enabledThemes.value.filter((theme) => theme !== 'System')
|
||||
}
|
||||
if (enabledThemes.value.length === 0) {
|
||||
enabledThemes.value = [...THEMES]
|
||||
}
|
||||
siteDefault = THEMES.includes(defaultTheme as Theme) && enabledThemes.value.includes(defaultTheme as Theme)
|
||||
? defaultTheme as Theme
|
||||
: enabledThemes.value.includes('System') ? 'System' : enabledThemes.value[0]!
|
||||
const saved = readSavedTheme()
|
||||
const next = THEMES.includes(saved as Theme) && enabledThemes.value.includes(saved as Theme)
|
||||
? saved as Theme
|
||||
: siteDefault
|
||||
// 用户保存的主题被禁用时回退到站点默认;回退结果不写回 localStorage,避免把系统代选固化为用户偏好
|
||||
selected.value = next
|
||||
if (next === 'System') {
|
||||
watchSystemTheme()
|
||||
applyTheme(resolveSystemTheme())
|
||||
} else {
|
||||
unwatchSystemTheme()
|
||||
applyTheme(next)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 主题图片 composable。
|
||||
* 提供与主题绑定的 SVG 图片路径,方便组件根据当前主题选择正确的图片。
|
||||
*
|
||||
* 图片存放在 public/themes/{ClassicDark,ClassicLight}/ 目录下,运行时通过 data-theme attribute 动态选择。
|
||||
*/
|
||||
import { useTheme } from '~/composables/useTheme'
|
||||
|
||||
export function useThemeImage() {
|
||||
const { currentTheme } = useTheme()
|
||||
|
||||
/** 获取指定键名的主题 SVG 路径 */
|
||||
function getThemeSvg(key: string) {
|
||||
return `/themes/${currentTheme.value}/${key}`
|
||||
}
|
||||
|
||||
/** 获取 Logo SVG 路径 */
|
||||
function getLogo() {
|
||||
return getThemeSvg('logo.svg')
|
||||
}
|
||||
|
||||
/** 获取搜索图标 SVG 路径 */
|
||||
function getSearchIcon() {
|
||||
return getThemeSvg('search.svg')
|
||||
}
|
||||
|
||||
/** 获取点赞图标 SVG 路径 */
|
||||
function getThumbsUpIcon() {
|
||||
return getThemeSvg('thumbs-up.svg')
|
||||
}
|
||||
|
||||
return {
|
||||
getLogo,
|
||||
getSearchIcon,
|
||||
getThumbsUpIcon
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { ref } from 'vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
interface ToastMessage {
|
||||
id: number
|
||||
message: string
|
||||
type: 'success' | 'error' | 'info' | 'warning'
|
||||
duration: number
|
||||
}
|
||||
|
||||
const toasts = ref<ToastMessage[]>([])
|
||||
let toastId = 0
|
||||
|
||||
const exactToastTranslations = new Map<string, string>([
|
||||
['需要登录才能点歌', 'Please sign in to request songs'],
|
||||
['需要登录才能投票', 'Please sign in to vote'],
|
||||
['您已经为这首歌投过票了', 'You have already voted for this song'],
|
||||
['需要登录才能撤回歌曲', 'Please sign in to withdraw songs'],
|
||||
['需要登录才能删除歌曲', 'Please sign in to delete songs'],
|
||||
['歌曲已成功删除', 'Song deleted successfully'],
|
||||
['歌曲已成功删除!', 'Song deleted successfully'],
|
||||
['需要登录才能标记歌曲', 'Please sign in to mark songs'],
|
||||
['歌曲已成功标记为已播放!', 'Song marked as played successfully'],
|
||||
['需要登录才能撤回歌曲已播放状态', 'Please sign in to revert played status'],
|
||||
['歌曲已成功撤回已播放状态!', 'Song played status reverted successfully'],
|
||||
['需要登录才能申请重播', 'Please sign in to request replay'],
|
||||
['申请重播成功', 'Replay request submitted'],
|
||||
['您已经申请过重播这首歌了', 'You have already requested replay for this song'],
|
||||
['需要登录才能取消重播申请', 'Please sign in to cancel replay request'],
|
||||
['已取消重播申请', 'Replay request canceled'],
|
||||
['没有上一首歌曲', 'No previous song'],
|
||||
['没有下一首歌曲', 'No next song'],
|
||||
['播放上一首歌曲失败', 'Failed to play previous song'],
|
||||
['播放下一首歌曲失败', 'Failed to play next song'],
|
||||
[
|
||||
'浏览器限制了自动播放,请点击播放器中的播放按钮继续播放',
|
||||
'The browser blocked autoplay. Click the play button in the player to continue.'
|
||||
],
|
||||
[
|
||||
'浏览器拦截了新标签页,请允许弹窗后重试',
|
||||
'The browser blocked the new tab. Allow pop-ups and try again.'
|
||||
],
|
||||
['已为你打开原始链接', 'Opened the original link for you'],
|
||||
['当前播放链接无效,已切换备用音源', 'Current playback link is invalid. Switched to a backup source.'],
|
||||
[
|
||||
'连续多首歌曲播放失败,已停止自动播放',
|
||||
'Multiple songs failed to play consecutively. Autoplay has been stopped.'
|
||||
],
|
||||
['哔哩哔哩视频播放失败,自动跳过', 'Bilibili video playback failed. Skipping automatically.'],
|
||||
['已切换为单曲循环', 'Switched to single-track loop'],
|
||||
['已切换为单曲播放', 'Switched to single-track playback'],
|
||||
['已切换为列表循环', 'Switched to playlist loop'],
|
||||
['请先登录网易云账号后再点赞评论', 'Please sign in to NetEase Cloud Music before liking comments'],
|
||||
['评论点赞失败', 'Failed to like comment'],
|
||||
['导入失败', 'Import failed'],
|
||||
['预览已刷新', 'Preview refreshed'],
|
||||
['正在准备打印', 'Preparing print...'],
|
||||
['正在准备打印...', 'Preparing print...'],
|
||||
['PDF导出成功', 'PDF exported successfully'],
|
||||
['正在准备PDF', 'Preparing PDF...'],
|
||||
['正在准备PDF...', 'Preparing PDF...'],
|
||||
['预览内容未找到', 'Preview content not found'],
|
||||
['排期内容过长,将自动分段导出', 'Schedule content is too long. It will be exported in sections.'],
|
||||
['图片导出成功', 'Image exported successfully'],
|
||||
['当前没有排期数据,请先在排期管理中添加排期', 'No schedule data yet. Add schedules in Schedule Management first.'],
|
||||
['快速合并内存不足,已切换兼容模式重试', 'Not enough memory for quick merge. Retrying in compatibility mode.'],
|
||||
['导入环境配置失败', 'Failed to import environment configuration'],
|
||||
['批量更新状态成功', 'Batch status updated successfully'],
|
||||
['用户更新成功', 'User updated successfully'],
|
||||
['用户创建成功', 'User created successfully'],
|
||||
['用户删除成功', 'User deleted successfully'],
|
||||
['禁止在用户管理中修改自己的账户', 'You cannot modify your own account in User Management'],
|
||||
['禁止在用户管理中重置自己的密码', 'You cannot reset your own password in User Management'],
|
||||
['不能删除自己的账户', 'You cannot delete your own account'],
|
||||
['密码重置成功', 'Password reset successfully'],
|
||||
[
|
||||
'Excel处理库加载失败,请刷新页面后重试',
|
||||
'Failed to load the Excel processing library. Refresh the page and try again.'
|
||||
],
|
||||
['歌曲已成功上传到网易云音乐云盘', 'Song uploaded to NetEase Cloud Music Drive successfully'],
|
||||
[
|
||||
'数据库恢复完成,3秒后将返回首页重新登录',
|
||||
'Database restore completed. Returning home to sign in again in 3 seconds.'
|
||||
],
|
||||
['无法解析备份文件,请检查文件格式是否正确', 'Unable to parse backup file. Please check the file format.'],
|
||||
['无法解析备份文件,请检查文件格式是否正确。', 'Unable to parse backup file. Please check the file format.'],
|
||||
['备份创建失败:无效的响应格式', 'Backup creation failed: invalid response format'],
|
||||
['备份创建失败:服务器响应格式错误', 'Backup creation failed: invalid server response format'],
|
||||
['文件下载失败', 'File download failed'],
|
||||
['未知错误', 'Unknown error']
|
||||
])
|
||||
|
||||
const toastOperationTranslations = new Map<string, string>([
|
||||
['播放', 'Playback'],
|
||||
['上传', 'Upload'],
|
||||
['预下载', 'Pre-download'],
|
||||
['合并', 'Merge'],
|
||||
['加载排期', 'Loading schedule'],
|
||||
['打印', 'Print'],
|
||||
['导出PDF', 'PDF export'],
|
||||
['导出长图', 'Long image export'],
|
||||
['文件下载', 'File download'],
|
||||
['删除用户', 'Deleting user'],
|
||||
['加载用户', 'Loading users'],
|
||||
['导入备份', 'Backup import'],
|
||||
['备份导入', 'Backup import'],
|
||||
['创建备份', 'Backup creation'],
|
||||
['保存用户', 'Saving user'],
|
||||
['重置密码', 'Password reset'],
|
||||
['批量更新', 'Batch update'],
|
||||
['添加歌曲', 'Adding song'],
|
||||
['更新歌曲', 'Updating song'],
|
||||
['删除歌曲', 'Deleting song'],
|
||||
['批量删除', 'Batch deletion'],
|
||||
['驳回歌曲', 'Rejecting song'],
|
||||
['刷新歌曲', 'Refreshing songs'],
|
||||
['标记已播放', 'Marking as played'],
|
||||
['标记未播放', 'Marking as unplayed'],
|
||||
['导入', 'Import']
|
||||
])
|
||||
|
||||
const translateToastDetail = (detail: string) => exactToastTranslations.get(detail.trim()) || detail
|
||||
|
||||
const normalizeToastMessage = (message: unknown): string => {
|
||||
if (typeof message === 'string') return message
|
||||
return String(message ?? '')
|
||||
}
|
||||
|
||||
const translateToastMessage = (message: unknown, locale: string) => {
|
||||
const normalizedMessage = normalizeToastMessage(message)
|
||||
if (locale === 'zh-CN') return normalizedMessage
|
||||
|
||||
const normalized = normalizedMessage.trim()
|
||||
const withoutPunctuation = normalized.replace(/[。!]$/, '')
|
||||
|
||||
const exactMatch = exactToastTranslations.get(normalized)
|
||||
if (exactMatch) return exactMatch
|
||||
|
||||
const punctuationlessMatch = exactToastTranslations.get(withoutPunctuation)
|
||||
if (punctuationlessMatch) return punctuationlessMatch
|
||||
|
||||
const dynamicRules: Array<[RegExp, (match: RegExpMatchArray) => string]> = [
|
||||
[/^已取消对歌曲《(.+)》的投票$/, (match) => `Vote for "${match[1]}" canceled`],
|
||||
[/^为歌曲《(.+)》投票成功!?$/, (match) => `Voted for "${match[1]}" successfully`],
|
||||
[/^备份文件已下载[::]\s*(.+)$/, (match) => `Backup file downloaded: ${match[1]}`],
|
||||
[/^文件下载失败[::]\s*(.+)$/, (match) => `File download failed: ${translateToastDetail(match[1])}`],
|
||||
[/^成功下载\s*(\d+)\s*首歌曲$/, (match) => `Downloaded ${match[1]} songs successfully`],
|
||||
[/^已完成分段导出,共\s*(\d+)\s*张图片$/, (match) => `Sectioned export completed with ${match[1]} images`],
|
||||
[/^部分更新成功,(\d+)\s*个用户因权限或状态等原因跳过$/, (match) => `Partially updated successfully. ${match[1]} users were skipped due to permissions or status.`],
|
||||
[/^成功更新\s*(\d+)\s*个用户的年级$/, (match) => `Updated grades for ${match[1]} users successfully`],
|
||||
[/^(.+?)失败[::]\s*(.+)$/, (match) => {
|
||||
const operation = toastOperationTranslations.get(match[1]) || match[1]
|
||||
return `${operation} failed: ${translateToastDetail(match[2])}`
|
||||
}]
|
||||
]
|
||||
|
||||
for (const [pattern, translate] of dynamicRules) {
|
||||
const match = normalized.match(pattern)
|
||||
if (match) return translate(match)
|
||||
}
|
||||
|
||||
return normalizedMessage
|
||||
}
|
||||
|
||||
export const useToast = () => {
|
||||
const { currentLocale } = useLocale()
|
||||
|
||||
const showToast = (
|
||||
message: unknown,
|
||||
type: 'success' | 'error' | 'info' | 'warning' = 'info',
|
||||
duration = 3000
|
||||
) => {
|
||||
const id = ++toastId
|
||||
const toast: ToastMessage = {
|
||||
id,
|
||||
message: translateToastMessage(message, currentLocale.value),
|
||||
type,
|
||||
duration
|
||||
}
|
||||
|
||||
toasts.value.push(toast)
|
||||
|
||||
// 自动移除toast
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
const removeToast = (id: number) => {
|
||||
const index = toasts.value.findIndex((toast) => toast.id === id)
|
||||
if (index > -1) {
|
||||
toasts.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const success = (message: unknown, duration?: number) => showToast(message, 'success', duration)
|
||||
const error = (message: unknown, duration?: number) => showToast(message, 'error', duration)
|
||||
const info = (message: unknown, duration?: number) => showToast(message, 'info', duration)
|
||||
const warning = (message: unknown, duration?: number) => showToast(message, 'warning', duration)
|
||||
|
||||
return {
|
||||
toasts,
|
||||
showToast,
|
||||
removeToast,
|
||||
success,
|
||||
error,
|
||||
info,
|
||||
warning
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { computed, watch } from 'vue'
|
||||
import type { User } from '~/types'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { smartSort } from '~/utils/gradeClassWeights'
|
||||
|
||||
type UsersListResponse = {
|
||||
success: boolean
|
||||
users: User[]
|
||||
pagination?: {
|
||||
page: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
export const useUserFilters = () => {
|
||||
const auth = useAuth()
|
||||
|
||||
const allUsers = useState<User[]>('admin-all-users', () => [])
|
||||
const loading = useState<boolean>('admin-all-users-loading', () => false)
|
||||
const isLoaded = useState<boolean>('admin-all-users-loaded', () => false)
|
||||
const lastError = useState<string>('admin-all-users-error', () => '')
|
||||
|
||||
// 用于存放轻量级后端返回的全局可选项(年级、班级关联)
|
||||
const cachedOptions = useState<{ grades: string[], classes: { grade: string | null, class: string }[] }>('admin-users-options', () => ({ grades: [], classes: [] }))
|
||||
const optionsLoaded = useState<boolean>('admin-users-options-loaded', () => false)
|
||||
|
||||
const fetchOptions = async () => {
|
||||
if (optionsLoaded.value) return
|
||||
|
||||
try {
|
||||
const res = await $fetch<{ success: boolean, grades: string[], classes: { grade: string | null, class: string }[] }>('/api/admin/users/options', {
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
if (res.success) {
|
||||
cachedOptions.value = {
|
||||
grades: res.grades || [],
|
||||
classes: res.classes || []
|
||||
}
|
||||
optionsLoaded.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取轻量级选项失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const waitForCurrentLoad = async () => {
|
||||
if (!loading.value) return
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const stop = watch(loading, (isLoading) => {
|
||||
if (!isLoading) {
|
||||
stop()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (lastError.value) {
|
||||
throw new Error(lastError.value)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAllUsers = async (force = false) => {
|
||||
if (isLoaded.value && !force) return allUsers.value
|
||||
if (loading.value) {
|
||||
await waitForCurrentLoad()
|
||||
return allUsers.value
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
lastError.value = ''
|
||||
try {
|
||||
let page = 1
|
||||
let hasMore = true
|
||||
let allFetchedUsers: User[] = []
|
||||
|
||||
while (hasMore) {
|
||||
const response = await $fetch<UsersListResponse>('/api/admin/users', {
|
||||
method: 'GET',
|
||||
query: {
|
||||
page,
|
||||
limit: 1000
|
||||
},
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error('获取用户列表失败')
|
||||
}
|
||||
|
||||
allFetchedUsers.push(...(response.users || []))
|
||||
|
||||
if (response.pagination && response.pagination.page < response.pagination.totalPages) {
|
||||
page++
|
||||
} else {
|
||||
hasMore = false
|
||||
}
|
||||
}
|
||||
|
||||
allUsers.value = allFetchedUsers
|
||||
isLoaded.value = true
|
||||
return allUsers.value
|
||||
} catch (err: any) {
|
||||
lastError.value = err?.data?.message || err?.message || '获取所有用户数据失败'
|
||||
console.error('获取所有用户数据失败:', err)
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getAvailableGrades = (users?: User[]) => {
|
||||
if (optionsLoaded.value) return cachedOptions.value.grades
|
||||
|
||||
if (!users || !Array.isArray(users)) return []
|
||||
return [...new Set(users.map((u) => u.grade).filter((g): g is string => Boolean(g)))].sort(smartSort)
|
||||
}
|
||||
|
||||
const getAvailableClasses = (users?: User[], gradeFilter?: string) => {
|
||||
if (optionsLoaded.value) {
|
||||
let c = cachedOptions.value.classes
|
||||
if (gradeFilter) {
|
||||
c = c.filter(item => item.grade === gradeFilter)
|
||||
}
|
||||
return [...new Set(c.map(item => item.class))].sort(smartSort)
|
||||
}
|
||||
|
||||
if (!users || !Array.isArray(users)) return []
|
||||
let filtered = users
|
||||
if (gradeFilter) {
|
||||
filtered = filtered.filter((u) => u.grade === gradeFilter)
|
||||
}
|
||||
return [...new Set(filtered.map((u) => u.class).filter((c): c is string => Boolean(c)))].sort(smartSort)
|
||||
}
|
||||
|
||||
const availableGrades = computed(() => {
|
||||
return getAvailableGrades(allUsers.value)
|
||||
})
|
||||
|
||||
const resetUserFilters = () => {
|
||||
allUsers.value = []
|
||||
isLoaded.value = false
|
||||
lastError.value = ''
|
||||
optionsLoaded.value = false
|
||||
cachedOptions.value = { grades: [], classes: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
allUsers,
|
||||
loading,
|
||||
isLoaded,
|
||||
fetchAllUsers,
|
||||
fetchOptions,
|
||||
getAvailableGrades,
|
||||
getAvailableClasses,
|
||||
availableGrades,
|
||||
resetUserFilters
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user