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

204 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="space-y-10 max-w-[1600px] mx-auto pb-20">
<div class="space-y-6">
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 px-1">
<div class="space-y-1">
<h2 class="text-3xl font-black text-zinc-100 tracking-tight">歌曲审核</h2>
<div class="flex items-center gap-6 mt-4">
<div class="flex items-center gap-2">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">待审核</span>
<span class="text-sm font-bold text-amber-500">{{ pendingAuditSongs.length }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
:disabled="loading"
class="flex items-center gap-2 px-5 py-2.5 bg-blue-600/10 hover:bg-blue-600/20 text-blue-400 text-xs font-bold rounded-lg border border-blue-500/20 transition-all disabled:opacity-50"
@click="refreshSongs(true)"
>
<RotateCcw :size="16" :class="{ 'animate-spin': loading }" /> 刷新
</button>
</div>
</div>
</div>
<!-- 歌曲表格 -->
<div class="bg-zinc-900/10 border border-zinc-800/40 rounded-xl overflow-hidden shadow-2xl relative">
<div v-if="loading" class="absolute inset-0 bg-zinc-950/40 backdrop-blur-sm z-10 flex items-center justify-center">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
<span class="text-xs font-bold text-zinc-400 tracking-widest uppercase">正在加载...</span>
</div>
</div>
<div class="hidden lg:grid grid-cols-12 gap-4 px-8 py-5 border-b border-zinc-800/60 bg-zinc-900/40">
<div class="col-span-4 text-[10px] font-black text-zinc-600 uppercase tracking-widest">歌曲信息</div>
<div class="col-span-2 text-[10px] font-black text-zinc-600 uppercase tracking-widest">投稿人</div>
<div class="col-span-3 text-[10px] font-black text-zinc-600 uppercase tracking-widest">期望排期</div>
<div class="col-span-3 text-[10px] font-black text-zinc-600 uppercase tracking-widest text-right pr-4">操作</div>
</div>
<div v-if="pendingAuditSongs.length === 0 && !loading" class="py-20 flex flex-col items-center justify-center text-zinc-500">
<Music :size="48" class="text-zinc-800 mb-4" />
<p class="text-sm font-bold">暂无待审核歌曲</p>
</div>
<div v-else class="divide-y divide-zinc-800/40">
<div
v-for="song in pendingAuditSongs"
:key="song.id"
class="grid grid-cols-1 lg:grid-cols-12 gap-4 px-6 lg:px-8 py-5 transition-all items-center hover:bg-zinc-800/20 group relative"
>
<div class="col-span-12 lg:col-span-4 flex items-center gap-4">
<div class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 border transition-all overflow-hidden bg-zinc-800/40 border-zinc-700/30 cursor-pointer hover:opacity-80" @click.stop="playSong(song)">
<img v-if="song.cover" :src="convertToHttps(song.cover)" class="w-full h-full object-cover" referrerpolicy="no-referrer">
<Music v-else :size="20" class="text-zinc-600" />
</div>
<div class="min-w-0">
<h4 class="font-bold transition-colors flex items-center text-zinc-100 group-hover:text-blue-400">
<span class="truncate">{{ song.title }}</span>
<button v-if="song.hasSubmissionNote && song.submissionNote" class="ml-2 inline-flex items-center justify-center w-5 h-5 rounded-full border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-all" title="查看备注留言" @click.stop="openSubmissionRemark(song)">
<MessageSquare :size="12" />
</button>
</h4>
<p class="text-xs text-zinc-500 font-medium truncate mt-0.5">{{ song.artist }}</p>
</div>
</div>
<div class="col-span-6 lg:col-span-2 flex flex-col lg:items-start">
<span class="text-xs font-bold text-zinc-400">{{ song.requester || '未知' }}</span>
<span v-if="song.user" class="text-[10px] font-bold text-zinc-600">@{{ song.user.username }}</span>
</div>
<div class="col-span-6 lg:col-span-3 flex flex-col">
<span v-if="song.expectedPlayDate" class="text-xs font-bold text-amber-500">
{{ song.expectedPlayDate }} 序号: {{ song.expectedPlaySequence }}
</span>
<span v-else class="text-xs font-bold text-zinc-500">未指定日期</span>
</div>
<div class="col-span-12 lg:col-span-3 flex items-center justify-end gap-2">
<button class="px-4 py-2 bg-emerald-600/10 hover:bg-emerald-600/20 text-emerald-500 hover:text-emerald-400 text-xs font-bold rounded-lg border border-emerald-500/20 transition-all" @click="auditSong(song.id, 'approved')">
通过
</button>
<button class="px-4 py-2 bg-red-600/10 hover:bg-red-600/20 text-red-500 hover:text-red-400 text-xs font-bold rounded-lg border border-red-500/20 transition-all" @click="rejectSong(song.id)">
未过审 (释放排期)
</button>
</div>
</div>
</div>
</div>
<!-- 驳回歌曲对话框 -->
<Transition enter-active-class="transition duration-300 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-200 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-if="showRejectDialog" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm">
<div class="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden" @click.stop>
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100">未过审处理</h3>
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="showRejectDialog = false"><X :size="20" /></button>
</div>
<div class="p-8 space-y-6">
<div class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg">
<p class="text-xs text-zinc-400 mb-2">此操作会将歌曲标记为未过审,并<span class="text-red-400 font-bold">立即释放其占用的排期序号</span>,供其他同学选择。歌曲不会被删除。</p>
<textarea v-model="rejectReason" placeholder="请输入未过审原因 (可选),将通知投稿人..." class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-red-500/30 min-h-[100px] resize-none transition-all" />
</div>
</div>
<div class="px-8 py-6 bg-zinc-900/50 border-t border-zinc-800/50 flex gap-3 justify-end">
<button class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300" @click="showRejectDialog = false">取消</button>
<button :disabled="auditLoading" class="px-8 py-2.5 bg-red-600 hover:bg-red-500 text-white text-xs font-black rounded-lg transition-all disabled:opacity-50" @click="confirmReject">
{{ auditLoading ? '处理中...' : '确认释放排期' }}
</button>
</div>
</div>
</div>
</Transition>
<SubmissionRemarkDialog :show="submissionRemarkDialog.show" :song-title="submissionRemarkDialog.songTitle" :content="submissionRemarkDialog.content" :is-public="submissionRemarkDialog.isPublic" @close="submissionRemarkDialog.show = false" />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import SubmissionRemarkDialog from '~/components/Admin/SubmissionRemarkDialog.vue'
import { RotateCcw, X, Music, MessageSquare } from '@lucide/vue'
import { useSongs } from '~/composables/useSongs'
import { useSongPlayer } from '~/composables/useSongPlayer'
import { useToast } from '~/composables/useToast'
import { convertToHttps } from '~/utils/url'
const { showToast } = useToast()
const { playSong } = useSongPlayer()
const loading = ref(false)
const auditLoading = ref(false)
const songs = ref([])
const showRejectDialog = ref(false)
const rejectReason = ref('')
const currentAuditId = ref(null)
const submissionRemarkDialog = ref({ show: false, songTitle: '', content: '', isPublic: false })
let songsService = null
const pendingAuditSongs = computed(() => {
return songs.value.filter(s =>
!s.played &&
!s.scheduled &&
(!s.submissionNote || (!s.submissionNote.includes('[审核未通过]') && !s.submissionNote.includes('[审核已通过]')))
)
})
const openSubmissionRemark = (song) => {
if (!song?.submissionNote) return
submissionRemarkDialog.value = {
show: true,
songTitle: `${song.title} - ${song.artist}`,
content: song.submissionNote,
isPublic: song.submissionNotePublic === true
}
}
const refreshSongs = async (bypassCache = false) => {
loading.value = true
try {
await songsService.fetchSongs(false, undefined, true, bypassCache)
songs.value = songsService.songs.value || []
} catch (error) {
console.error('刷新歌曲失败:', error)
} finally {
loading.value = false
}
}
const auditSong = async (id, status, reason = '') => {
try {
await $fetch('/api/admin/songs/audit', {
method: 'POST',
body: { songId: id, status, reason }
})
showToast(status === 'approved' ? '审核已通过' : '已驳回并释放排期', 'success')
await refreshSongs(true)
} catch (err) {
showToast('操作失败: ' + (err.data?.message || err.message), 'error')
}
}
const rejectSong = (id) => {
currentAuditId.value = id
rejectReason.value = ''
showRejectDialog.value = true
}
const confirmReject = async () => {
auditLoading.value = true
await auditSong(currentAuditId.value, 'rejected', rejectReason.value)
showRejectDialog.value = false
auditLoading.value = false
}
onMounted(async () => {
songsService = useSongs()
await refreshSongs()
})
</script>