969 lines
28 KiB
TypeScript
969 lines
28 KiB
TypeScript
import { computed, ref } from 'vue'
|
||
import { useAuth } from './useAuth'
|
||
import { getGlobalDedup } from './useRequestDedup'
|
||
import type { PlayTime, Schedule, Song } from '~~/types'
|
||
|
||
export const useSongs = () => {
|
||
const { isAuthenticated, user, getAuthConfig, isAdmin } = useAuth()
|
||
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 similarSongFound = ref<Song | null>(null)
|
||
const playTimes = ref<PlayTime[]>([])
|
||
const playTimeEnabled = ref(false)
|
||
const songCount = ref(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,
|
||
bypassCache = false
|
||
) => {
|
||
if (!silent) {
|
||
loading.value = true
|
||
}
|
||
error.value = ''
|
||
|
||
try {
|
||
const requestParams = semester ? { semester } : undefined
|
||
|
||
const response = await dedup.dedupedRequest(
|
||
'songs',
|
||
async () => {
|
||
// 构建URL参数
|
||
const params = new URLSearchParams()
|
||
if (semester) {
|
||
params.append('semester', semester)
|
||
}
|
||
// 只有当 bypassCache 为 true 时才添加 bypass_cache 参数
|
||
if (bypassCache) {
|
||
params.append('bypass_cache', 'true')
|
||
}
|
||
if (forceRefresh) {
|
||
params.append('t', Date.now().toString())
|
||
}
|
||
const url = `/api/songs${params.toString() ? '?' + params.toString() : ''}`
|
||
|
||
// API请求
|
||
return await $fetch(url, {
|
||
...getAuthConfig()
|
||
})
|
||
},
|
||
requestParams,
|
||
forceRefresh
|
||
)
|
||
|
||
// 正确解析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) {
|
||
error.value = err.message || '获取歌曲列表失败'
|
||
} finally {
|
||
if (!silent) {
|
||
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
|
||
}
|
||
songsMap.set(songId, completeSong)
|
||
}
|
||
}
|
||
})
|
||
|
||
return Array.from(songsMap.values())
|
||
}
|
||
|
||
// 获取公共排期(无需登录)
|
||
const fetchPublicSchedules = async (
|
||
silent = false,
|
||
semester?: string,
|
||
forceRefresh = false,
|
||
bypassCache = false
|
||
) => {
|
||
if (!silent) {
|
||
loading.value = true
|
||
}
|
||
error.value = ''
|
||
|
||
try {
|
||
const requestParams = semester ? { semester } : undefined
|
||
|
||
const data = await dedup.dedupedRequest(
|
||
'public-schedules',
|
||
async () => {
|
||
// 构建URL参数
|
||
const params = new URLSearchParams()
|
||
if (semester) {
|
||
params.append('semester', semester)
|
||
}
|
||
// 只有当 bypassCache 为 true 时才添加 bypass_cache 参数
|
||
if (bypassCache) {
|
||
params.append('bypass_cache', 'true')
|
||
}
|
||
if (forceRefresh) {
|
||
params.append('t', Date.now().toString())
|
||
}
|
||
const url = `/api/songs/public${params.toString() ? '?' + params.toString() : ''}`
|
||
|
||
const response = await $fetch(url, {
|
||
...getAuthConfig()
|
||
})
|
||
return response
|
||
},
|
||
requestParams,
|
||
forceRefresh
|
||
)
|
||
|
||
// 确保每个排期的歌曲都有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) {
|
||
error.value = err.message || '获取排期失败'
|
||
} finally {
|
||
if (!silent) {
|
||
loading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// 静默刷新公共排期 - 不显示加载状态
|
||
const refreshSchedulesSilent = async () => {
|
||
return fetchPublicSchedules(true)
|
||
}
|
||
|
||
// 字符串相似度计算(编辑距离)
|
||
const calculateSimilarity = (str1: string, str2: string): number => {
|
||
const len1 = str1.length
|
||
const len2 = str2.length
|
||
|
||
if (len1 === 0) return len2
|
||
if (len2 === 0) return len1
|
||
|
||
const matrix = Array(len1 + 1)
|
||
.fill(null)
|
||
.map(() => Array(len2 + 1).fill(null))
|
||
|
||
for (let i = 0; i <= len1; i++) matrix[i][0] = i
|
||
for (let j = 0; j <= len2; j++) matrix[0][j] = j
|
||
|
||
for (let i = 1; i <= len1; i++) {
|
||
for (let j = 1; j <= len2; j++) {
|
||
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
|
||
matrix[i][j] = Math.min(
|
||
matrix[i - 1][j] + 1, // 删除
|
||
matrix[i][j - 1] + 1, // 插入
|
||
matrix[i - 1][j - 1] + cost // 替换
|
||
)
|
||
}
|
||
}
|
||
|
||
const maxLen = Math.max(len1, len2)
|
||
return (maxLen - matrix[len1][len2]) / maxLen
|
||
}
|
||
|
||
// 标准化字符串(去除标点符号、空格,转换为小写)
|
||
const normalizeString = (str: string): string => {
|
||
return str
|
||
.toLowerCase()
|
||
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉""''""''、,。!?:;~·]/g, '')
|
||
.replace(/[&&]/g, 'and')
|
||
.replace(/[feat\.?|ft\.?]/gi, '')
|
||
.trim()
|
||
}
|
||
|
||
// 获取当前学期名称(从数据库)
|
||
const getCurrentSemesterName = async () => {
|
||
try {
|
||
const response = await fetch('/api/semesters/current')
|
||
if (!response.ok) {
|
||
throw new Error('获取当前学期失败')
|
||
}
|
||
const data = await response.json()
|
||
return data?.name || null
|
||
} catch (error) {
|
||
console.error('获取当前学期失败:', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 检查相似歌曲
|
||
const checkSimilarSongs = async (title: string, artist: string): Promise<Song[]> => {
|
||
similarSongFound.value = null
|
||
|
||
const normalizedTitle = normalizeString(title)
|
||
const normalizedArtist = normalizeString(artist)
|
||
|
||
// 获取当前学期名称
|
||
const currentSemesterName = await getCurrentSemesterName()
|
||
|
||
// 1. 检查完全相同的歌曲(标准化后)
|
||
const exactSongs = songs.value.filter((song) => {
|
||
const songTitle = normalizeString(song.title)
|
||
const songArtist = normalizeString(song.artist)
|
||
const titleMatch = songTitle === normalizedTitle && songArtist === normalizedArtist
|
||
|
||
// 如果有当前学期信息,只检查当前学期的歌曲
|
||
if (currentSemesterName) {
|
||
return titleMatch && song.semester === currentSemesterName
|
||
}
|
||
|
||
// 如果没有学期信息,检查所有歌曲(向后兼容)
|
||
return titleMatch
|
||
})
|
||
|
||
if (exactSongs.length > 0) {
|
||
return exactSongs
|
||
}
|
||
|
||
// 2. 检查高度相似的歌曲
|
||
const similarSongs: Array<{ song: Song; similarity: number }> = []
|
||
|
||
songs.value.forEach((song) => {
|
||
// 如果有当前学期信息,只检查当前学期的歌曲
|
||
if (currentSemesterName && song.semester !== currentSemesterName) {
|
||
return
|
||
}
|
||
|
||
const songTitle = normalizeString(song.title)
|
||
const songArtist = normalizeString(song.artist)
|
||
|
||
// 计算标题相似度
|
||
const titleSimilarity = calculateSimilarity(normalizedTitle, songTitle)
|
||
|
||
// 计算艺术家相似度
|
||
const artistSimilarity = calculateSimilarity(normalizedArtist, songArtist)
|
||
|
||
// 检查是否为高度相似
|
||
const isHighlySimilar =
|
||
// 标题完全相同,艺术家高度相似
|
||
(titleSimilarity >= 0.95 && artistSimilarity >= 0.8) ||
|
||
// 标题高度相似,艺术家完全相同
|
||
(titleSimilarity >= 0.8 && artistSimilarity >= 0.95) ||
|
||
// 标题和艺术家都高度相似
|
||
(titleSimilarity >= 0.85 && artistSimilarity >= 0.85) ||
|
||
// 标题包含关系且艺术家相似
|
||
((songTitle.includes(normalizedTitle) || normalizedTitle.includes(songTitle)) &&
|
||
artistSimilarity >= 0.8 &&
|
||
Math.abs(songTitle.length - normalizedTitle.length) <= 3)
|
||
|
||
if (isHighlySimilar) {
|
||
const overallSimilarity = titleSimilarity * 0.7 + artistSimilarity * 0.3
|
||
similarSongs.push({ song, similarity: overallSimilarity })
|
||
}
|
||
})
|
||
|
||
// 3. 按相似度排序并返回
|
||
if (similarSongs.length > 0) {
|
||
const sortedSimilar = similarSongs
|
||
.sort((a, b) => b.similarity - a.similarity)
|
||
.map((item) => item.song)
|
||
|
||
similarSongFound.value = sortedSimilar[0] // 保持兼容性
|
||
return sortedSimilar
|
||
}
|
||
|
||
// 4. 如果没有高度相似的,检查可能的相似歌曲(降低阈值)
|
||
const possibleSimilar: Array<{ song: Song; similarity: number }> = []
|
||
|
||
songs.value.forEach((song) => {
|
||
// 如果有当前学期信息,只检查当前学期的歌曲
|
||
if (currentSemesterName && song.semester !== currentSemesterName) {
|
||
return
|
||
}
|
||
|
||
const songTitle = normalizeString(song.title)
|
||
const songArtist = normalizeString(song.artist)
|
||
|
||
const titleSimilarity = calculateSimilarity(normalizedTitle, songTitle)
|
||
const artistSimilarity = calculateSimilarity(normalizedArtist, songArtist)
|
||
|
||
// 检查可能相似的条件(更宽松)
|
||
const isPossiblySimilar =
|
||
// 标题相似度较高
|
||
(titleSimilarity >= 0.7 && artistSimilarity >= 0.6) ||
|
||
// 标题包含关系
|
||
((songTitle.includes(normalizedTitle) || normalizedTitle.includes(songTitle)) &&
|
||
artistSimilarity >= 0.5 &&
|
||
normalizedTitle.length >= 3)
|
||
|
||
if (isPossiblySimilar) {
|
||
const overallSimilarity = titleSimilarity * 0.7 + artistSimilarity * 0.3
|
||
possibleSimilar.push({ song, similarity: overallSimilarity })
|
||
}
|
||
})
|
||
|
||
if (possibleSimilar.length > 0) {
|
||
const sortedPossible = possibleSimilar
|
||
.sort((a, b) => b.similarity - a.similarity)
|
||
.slice(0, 3) // 最多返回3个可能相似的歌曲
|
||
.map((item) => item.song)
|
||
|
||
similarSongFound.value = sortedPossible[0] // 保持兼容性
|
||
return sortedPossible
|
||
}
|
||
|
||
return []
|
||
}
|
||
|
||
// 请求歌曲
|
||
const requestSong = async (songData: {
|
||
title: string
|
||
artist: string
|
||
preferredPlayTimeId?: number | null
|
||
cover?: string | null
|
||
musicPlatform?: string | null
|
||
musicId?: string | null
|
||
playUrl?: string | null
|
||
expectedPlayDate?: string | null
|
||
expectedPlaySequence?: number | null
|
||
collaborators?: number[]
|
||
submissionNote?: string | null
|
||
submissionNotePublic?: boolean
|
||
}) => {
|
||
if (!isAuthenticated.value) {
|
||
showNotification('需要登录才能点歌', '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, true)
|
||
|
||
return data
|
||
} catch (err: any) {
|
||
const errorMsg = err.data?.message || err.message || '点歌失败'
|
||
// 如果是重复投稿错误,只显示通知而不设置全局错误
|
||
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('需要登录才能投票', '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('您已经为这首歌投过票了', '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(`已取消对歌曲《${targetSong.title}》的投票`, 'success')
|
||
} else {
|
||
// 正常投票
|
||
targetSong.voted = true
|
||
targetSong.voteCount = (targetSong.voteCount || 0) + 1
|
||
showNotification(`为歌曲《${targetSong.title}》投票成功!`, 'success')
|
||
}
|
||
}
|
||
|
||
return data
|
||
} catch (fetchErr: any) {
|
||
// 处理API返回的错误
|
||
const errorMsg = fetchErr.data?.message || fetchErr.message || '投票失败'
|
||
|
||
// 如果是已投票错误,只显示通知,不抛出异常
|
||
if (errorMsg.includes('已经为这首歌投过票')) {
|
||
showNotification('您已经为这首歌投过票了', '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('需要登录才能撤回歌曲', 'error')
|
||
return null
|
||
}
|
||
|
||
loading.value = true
|
||
error.value = ''
|
||
|
||
try {
|
||
// 查找歌曲信息用于通知
|
||
const targetSong = songs.value.find((s) => s.id === songId)
|
||
const songTitle = targetSong ? targetSong.title : '歌曲'
|
||
|
||
// 使用认证配置
|
||
const authConfig = getAuthConfig()
|
||
|
||
const data = await $fetch('/api/songs/withdraw', {
|
||
method: 'POST',
|
||
body: { songId },
|
||
...authConfig
|
||
})
|
||
|
||
// 更新歌曲列表
|
||
await fetchSongs(true, undefined, true, true)
|
||
|
||
// 显示成功通知
|
||
let message = ''
|
||
if (data.action === 'leave') {
|
||
message = `已成功退出歌曲《${songTitle}》的联合投稿`
|
||
} else if (data.message) {
|
||
// 如果后端返回了特定消息,优先使用(除了撤回投稿的情况,我们要保留带标题的格式)
|
||
message = data.message
|
||
} else {
|
||
message = data.quotaReturned
|
||
? `已成功撤回《${songTitle}》的投稿,投稿配额已返还`
|
||
: `已成功撤回《${songTitle}》的投稿`
|
||
}
|
||
|
||
if (data.action !== 'leave') {
|
||
message = data.quotaReturned
|
||
? `已成功撤回《${songTitle}》的投稿,投稿配额已返还`
|
||
: `已成功撤回《${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('需要登录才能删除歌曲', '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 Promise.all([
|
||
fetchSongs(true, undefined, true, true),
|
||
fetchPublicSchedules(true, undefined, true, true)
|
||
])
|
||
|
||
showNotification('歌曲已成功删除!', '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('需要登录才能标记歌曲', '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 Promise.all([
|
||
fetchSongs(true, undefined, true, true),
|
||
fetchPublicSchedules(true, undefined, true, true)
|
||
])
|
||
|
||
showNotification('歌曲已成功标记为已播放!', '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('需要登录才能撤回歌曲已播放状态', '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 Promise.all([
|
||
fetchSongs(true, undefined, true, true),
|
||
fetchPublicSchedules(true, undefined, true, true)
|
||
])
|
||
|
||
showNotification('歌曲已成功撤回已播放状态!', '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) => {
|
||
if (!isAuthenticated.value) {
|
||
showNotification('需要登录才能申请重播', 'error')
|
||
return null
|
||
}
|
||
|
||
loading.value = true
|
||
error.value = ''
|
||
|
||
try {
|
||
const authConfig = getAuthConfig()
|
||
const data = await $fetch('/api/songs/replay', {
|
||
method: 'POST',
|
||
body: { songId },
|
||
...authConfig
|
||
})
|
||
|
||
// 更新本地数据状态
|
||
const songIndex = songs.value.findIndex((s) => s.id === songId)
|
||
if (songIndex !== -1) {
|
||
songs.value[songIndex].replayRequested = true
|
||
}
|
||
|
||
// 更新歌曲列表
|
||
await fetchSongs(true, undefined, true, true)
|
||
|
||
showNotification('重播申请已提交,请等待管理员审核', 'success')
|
||
return data
|
||
} catch (err: any) {
|
||
const errorMsg = err.data?.message || err.message || '申请重播失败'
|
||
if (errorMsg.includes('已经申请')) {
|
||
showNotification('您已经申请过重播这首歌了', 'info')
|
||
} else {
|
||
showNotification(errorMsg, 'error')
|
||
}
|
||
return null
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 撤回重播申请
|
||
* @param songId 歌曲ID
|
||
*/
|
||
const withdrawReplay = async (songId: number) => {
|
||
const { user } = useAuth()
|
||
if (!user.value) {
|
||
showNotification('需要登录才能取消重播申请', 'error')
|
||
return null
|
||
}
|
||
|
||
loading.value = true
|
||
error.value = ''
|
||
|
||
try {
|
||
const authConfig = getAuthConfig()
|
||
const data = await $fetch('/api/songs/replay', {
|
||
method: 'DELETE',
|
||
body: { songId },
|
||
...authConfig
|
||
})
|
||
|
||
// 更新歌曲列表
|
||
await fetchSongs(true, undefined, true, true)
|
||
|
||
showNotification('已取消重播申请', 'success')
|
||
return data
|
||
} catch (err: any) {
|
||
const errorMsg = err.data?.message || err.message || '取消重播申请失败'
|
||
showNotification(errorMsg, '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(() => {
|
||
if (songs.value && songs.value.length > 0) {
|
||
return songs.value
|
||
} else {
|
||
return publicSongs.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 response = await dedup.dedupedRequest('song-count', async () => {
|
||
const response = await $fetch(`/api/songs/count${forceRefresh ? '?t=' + Date.now() : ''}`)
|
||
return response
|
||
}, undefined, forceRefresh)
|
||
|
||
// 正确解析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,
|
||
similarSongFound,
|
||
playTimes,
|
||
playTimeEnabled,
|
||
showNotification,
|
||
fetchSongs,
|
||
fetchPublicSchedules,
|
||
fetchPlayTimes,
|
||
fetchSongCount,
|
||
refreshSongsSilent,
|
||
refreshSchedulesSilent,
|
||
checkSimilarSongs,
|
||
requestSong,
|
||
voteSong,
|
||
withdrawSong,
|
||
deleteSong,
|
||
markPlayed,
|
||
unmarkPlayed,
|
||
requestReplay,
|
||
withdrawReplay,
|
||
filterSchedulesByPlayTime,
|
||
getPlayTimeName,
|
||
formatPlayTimeDisplay,
|
||
extractSongsFromSchedules,
|
||
initialize,
|
||
songsByPopularity,
|
||
songsByDate,
|
||
playedSongs,
|
||
unplayedSongs,
|
||
mySongs,
|
||
myReplaySongs
|
||
}
|
||
}
|