Files
BYW-VoiceHub/app/components/Admin/SongManagement.vue
T
Henry_Du 9fc5e3e607
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Canceled after 0s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Canceled after 0s
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Canceled after 1m27s
fix(ui): 批量替换所有组件中残留的硬编码深色值以彻底支持浅色主题
2026-09-12 15:56:10 +08:00

2189 lines
74 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-zinc-300">{{ filteredSongs.length }}</span>
</div>
<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-emerald-500">{{ playedCount }}</span>
</div>
<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-blue-500">{{ pendingCount }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<!-- 批量操作 -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 scale-95 translate-x-10"
enter-to-class="opacity-100 scale-100 translate-x-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 scale-100 translate-x-0"
leave-to-class="opacity-0 scale-95 translate-x-10"
>
<div
v-if="selectedSongs.length > 0"
class="flex items-center gap-2 p-1.5 bg-zinc-900/90 border border-zinc-800 rounded-lg backdrop-blur-xl shadow-2xl"
>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-emerald-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
@click="openDownloadDialog"
>
<Download :size="14" class="text-emerald-500/70" /> 下载 ({{
selectedSongs.length
}})
</button>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-emerald-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
@click="batchMarkPlayed"
>
<Check :size="14" class="text-emerald-500/70" /> 已播放 ({{ selectedSongs.length }})
</button>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-blue-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
@click="batchMarkUnplayed"
>
<RotateCcw :size="14" class="text-blue-500/70" /> 未播放 ({{ selectedSongs.length }})
</button>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-amber-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
@click="batchReject"
>
<X :size="14" class="text-amber-500/70" /> 驳回 ({{ selectedSongs.length }})
</button>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-red-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
@click="batchDelete"
>
<Trash2 :size="14" class="text-red-500/70" /> 删除 ({{ selectedSongs.length }})
</button>
</div>
</Transition>
<button
class="flex items-center gap-2 px-5 py-2.5 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-300 text-xs font-bold rounded-lg transition-all active:scale-95"
@click="openAddSongModal"
>
<Plus :size="16" /> 手动添加
</button>
<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
class="bg-zinc-900/30 border border-zinc-800/60 rounded-xl p-4 flex flex-col lg:flex-row gap-4 items-stretch lg:items-center"
>
<div class="relative flex-1 group w-full lg:w-auto">
<Search
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700 group-focus-within:text-blue-500 transition-colors"
:size="18"
/>
<input
v-model="searchQuery"
type="text"
placeholder="搜索歌曲标题、艺术家或投稿人..."
class="w-full bg-zinc-950/50 border border-zinc-800/80 rounded-lg pl-12 pr-4 py-3 text-sm focus:outline-none focus:border-blue-500/30 transition-all placeholder:text-zinc-800 text-zinc-200"
>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:flex lg:items-center gap-3 w-full lg:w-auto">
<CustomSelect
v-model="selectedSemester"
label="学期"
:options="availableSemesters"
label-key="name"
value-key="name"
placeholder="选择学期"
class-name="w-full lg:w-40"
/>
<CustomSelect
v-model="statusFilter"
label="状态"
:options="statusOptions"
placeholder="选择状态"
class-name="w-full lg:w-32"
/>
<CustomSelect
v-model="sortOption"
label="排序"
:options="sortOptions"
placeholder="选择排序"
class-name="w-full lg:w-36 col-span-2 md:col-span-1"
/>
</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-1 flex items-center">
<input
type="checkbox"
:checked="isAllSelected"
class="w-5 h-5 rounded-lg border-2 border-zinc-800 bg-zinc-950 accent-blue-600 transition-all cursor-pointer"
@change="toggleSelectAll"
>
</div>
<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-1 text-[10px] font-black text-zinc-600 uppercase tracking-widest text-center"
>
统计
</div>
<div
class="col-span-2 text-[10px] font-black text-zinc-600 uppercase tracking-widest text-center"
>
状态
</div>
<div
class="col-span-2 text-[10px] font-black text-zinc-600 uppercase tracking-widest text-right pr-4"
>
操作
</div>
</div>
<!-- 空状态 -->
<div
v-if="filteredSongs.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">{{ searchQuery ? '没有找到匹配的歌曲' : '暂无歌曲数据' }}</p>
</div>
<!-- 列表内容 -->
<div v-else class="divide-y divide-zinc-800/40">
<div
v-for="song in paginatedSongs"
: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 group relative',
selectedSongs.includes(song.id) ? 'bg-blue-600/5' : 'hover:bg-zinc-800/20'
]"
>
<div class="hidden lg:flex col-span-1 items-center">
<input
type="checkbox"
:checked="selectedSongs.includes(song.id)"
class="w-5 h-5 rounded-lg border-2 border-zinc-800 bg-zinc-950 accent-blue-600 transition-all cursor-pointer"
@change="toggleSelectSong(song.id)"
>
</div>
<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 group-hover:border-zinc-600 cursor-pointer hover:opacity-80',
selectedSongs.includes(song.id) ? 'border-blue-500/50' : ''
]"
@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',
selectedSongs.includes(song.id)
? 'text-blue-400'
: 'text-zinc-100 group-hover:text-blue-400'
]"
>
<span
v-if="false"
class="truncate flex items-center gap-1 text-left"
>
{{ song.title }}
</span>
<span v-else 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 v-if="song.expectedPlayDate" class="mt-1 flex items-center">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded border border-blue-500/20 text-[10px] font-medium">
<CalendarDays :size="10" />
期望: {{ song.expectedPlayDate }}
<template v-if="song.expectedPlaySequence">
第 {{ song.expectedPlaySequence }} 首
</template>
</span>
</div>
<span
class="lg:hidden text-[9px] font-black text-zinc-700 uppercase tracking-wider mt-1 inline-block"
>{{ formatDate(song.createdAt) }}</span
>
</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
>
<span
class="hidden lg:inline text-[9px] font-black text-zinc-700 uppercase tracking-widest mt-1 opacity-60"
>{{ formatDate(song.createdAt) }}</span
>
</div>
<div class="col-span-3 lg:col-span-1 text-center">
<button
:disabled="!(song.voteCount > 0)"
:class="[
'inline-flex items-center gap-1.5 text-xs font-bold px-2 py-1 rounded-lg transition-all',
song.voteCount > 0
? 'bg-pink-500/10 text-pink-500 hover:bg-pink-500/20 cursor-pointer'
: 'bg-zinc-950/30 text-zinc-700 cursor-default'
]"
@click="showVoters(song.id)"
>
<Heart :size="12" :class="song.voteCount > 0 ? 'fill-pink-500/20' : ''" />
{{ song.voteCount || 0 }}
</button>
</div>
<div class="col-span-3 lg:col-span-2 text-center">
<span
:class="[
'px-2 py-0.5 text-[10px] font-black rounded-md uppercase tracking-wider',
song.played
? 'bg-emerald-500/10 text-emerald-500'
: song.scheduled
? 'bg-blue-500/10 text-blue-500'
: 'bg-zinc-800 text-zinc-500'
]"
>
{{ getStatusText(song) }}
</span>
</div>
<div
class="col-span-12 lg:col-span-2 flex items-center justify-end gap-2 opacity-100 lg:opacity-0 group-hover:opacity-100 transition-all"
>
<button
class="p-2 bg-zinc-800/50 text-blue-400 hover:bg-blue-600 hover:text-white rounded-xl transition-all border border-zinc-700/30"
title="编辑歌曲"
@click="editSong(song)"
>
<Edit2 :size="14" />
</button>
<button
v-if="!song.played"
class="p-2 bg-zinc-800/50 text-emerald-400 hover:bg-emerald-600 hover:text-white rounded-xl transition-all border border-zinc-700/30"
title="标记为已播放"
@click="markAsPlayed(song.id)"
>
<Check :size="14" />
</button>
<button
v-else
class="p-2 bg-zinc-800/50 text-zinc-400 hover:bg-zinc-600 hover:text-white rounded-xl transition-all border border-zinc-700/30"
title="标记为未播放"
@click="markAsUnplayed(song.id)"
>
<RotateCcw :size="14" />
</button>
<button
class="p-2 bg-zinc-800/50 text-amber-500 hover:bg-amber-600 hover:text-white rounded-xl transition-all border border-zinc-700/30"
title="驳回歌曲"
@click="rejectSong(song.id)"
>
<X :size="14" />
</button>
<button
class="p-2 bg-zinc-800/50 text-red-400 hover:bg-red-600 hover:text-white rounded-xl transition-all border border-zinc-700/30"
title="删除歌曲"
@click="deleteSong(song.id)"
>
<Trash2 :size="14" />
</button>
</div>
</div>
</div>
</div>
<!-- 分页区域 -->
<Pagination
v-model:current-page="currentPage"
:total-pages="totalPages"
:total-items="filteredSongs.length"
item-name="首歌曲"
/>
<!-- Modals (模态框) -->
<!-- 确认删除对话框 -->
<ConfirmDialog
:show="showDeleteDialog"
:title="deleteDialogTitle"
:message="deleteDialogMessage"
:loading="loading"
type="danger"
confirm-text="确认删除"
cancel-text="取消"
@confirm="confirmDelete"
@close="showDeleteDialog = false"
/>
<SubmissionRemarkDialog
:show="submissionRemarkDialog.show"
:song-title="submissionRemarkDialog.songTitle"
:content="submissionRemarkDialog.content"
:is-public="submissionRemarkDialog.isPublic"
@close="submissionRemarkDialog.show = false"
/>
<!-- 驳回歌曲对话框 -->
<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">{{ isBatchReject ? `批量驳回歌曲 (${selectedSongs.length} 首)` : '驳回歌曲' }}</h3>
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors"
@click="cancelReject"
>
<X :size="20" />
</button>
</div>
<div class="p-8 space-y-6">
<div v-if="!isBatchReject" class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4">
<div
class="w-10 h-10 rounded-lg bg-red-500/10 text-red-500 flex items-center justify-center border border-red-500/10"
>
<Music :size="18" />
</div>
<div>
<h4 class="font-bold text-zinc-100 text-sm">{{ rejectSongInfo.title }}</h4>
<p class="text-xs text-zinc-500">投稿人: {{ rejectSongInfo.requester }}</p>
</div>
</div>
<div v-else class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4">
<div
class="w-10 h-10 rounded-lg bg-amber-500/10 text-amber-500 flex items-center justify-center border border-amber-500/10"
>
<ListMusic :size="18" />
</div>
<div>
<h4 class="font-bold text-zinc-100 text-sm">即将批量驳回 {{ selectedSongs.length }} 首歌曲</h4>
<p class="text-xs text-zinc-500">驳回后将一并清理投票、排期记录并发送通知给对应投稿人</p>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>驳回原因</label
>
<textarea
v-model="rejectReason"
placeholder="请输入驳回原因,将通过系统通知发送给投稿人..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-4 text-sm text-zinc-200 focus:outline-none focus:border-red-500/30 min-h-[120px] resize-none transition-all"
/>
</div>
<label class="flex items-center gap-3 cursor-pointer group px-1">
<input
v-model="addToBlacklist"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-950 accent-red-600"
>
<div>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-red-400 transition-colors"
>同时将此歌曲加入黑名单</span
>
<p class="text-[10px] text-zinc-600 font-medium">
加入黑名单后,该歌曲将无法再次被投稿
</p>
</div>
</label>
</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="cancelReject"
>
取消
</button>
<button
:disabled="rejectLoading"
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"
>
{{ rejectLoading ? '处理中...' : '确认驳回' }}
</button>
</div>
</div>
</div>
</Transition>
<!-- 投票人员弹窗 -->
<VotersModal :show="showVotersModal" :song-id="selectedSongId" @close="closeVotersModal" />
<!-- 下载歌曲对话框 -->
<SongDownloadDialog
:show="showDownloadDialog"
:songs="selectedSongsForDownload"
@close="closeDownloadDialog"
/>
<!-- 编辑/添加歌曲模态框 -->
<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="showEditModal || showAddSongModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm overflow-y-auto"
>
<div
class="w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden my-auto"
@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">
{{ showEditModal ? '编辑歌曲' : '手动添加歌曲' }}
</h3>
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors"
@click="showEditModal ? cancelEditSong() : cancelAddSong()"
>
<X :size="20" />
</button>
</div>
<div class="p-8 space-y-6 max-h-[70vh] overflow-y-auto custom-scrollbar">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>歌曲名称</label
>
<input
v-if="showEditModal"
v-model="editForm.title"
type="text"
placeholder="输入歌曲标题"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
>
<input
v-else
v-model="addForm.title"
type="text"
placeholder="输入歌曲标题"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>歌手</label
>
<input
v-if="showEditModal"
v-model="editForm.artist"
type="text"
placeholder="输入歌手姓名"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
>
<input
v-else
v-model="addForm.artist"
type="text"
placeholder="输入歌手姓名"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>投稿人</label
>
<div class="relative user-search-container">
<div class="relative">
<input
v-if="showEditModal"
v-model="editUserSearchQuery"
type="text"
placeholder="搜索用户姓名或用户名"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
@focus="showEditUserDropdown = true"
@input="searchEditUsers()"
>
<input
v-else
v-model="userSearchQuery"
type="text"
placeholder="搜索用户姓名或用户名"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
@focus="showUserDropdown = true"
@input="searchUsers()"
>
<div
v-if="showEditModal ? editUserSearchLoading : userSearchLoading"
class="absolute right-4 top-1/2 -translate-y-1/2"
>
<div
class="w-4 h-4 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
/>
</div>
</div>
<!-- 用户下拉列表 -->
<div
v-if="
showEditModal
? showEditUserDropdown && filteredEditUsers.length > 0
: showUserDropdown && filteredUsers.length > 0
"
class="absolute z-10 w-full mt-2 bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl overflow-hidden max-h-48 overflow-y-auto"
>
<div
v-for="user in showEditModal
? filteredEditUsers.slice(0, 10)
: filteredUsers.slice(0, 10)"
:key="user.id"
class="px-4 py-3 hover:bg-zinc-800 cursor-pointer transition-colors flex items-center justify-between group"
@click="showEditModal ? selectEditUser(user) : selectUser(user)"
>
<div class="flex flex-col">
<span class="text-sm font-bold text-zinc-200">{{ user.name }}</span>
<span class="text-[10px] text-zinc-500">@{{ user.username }}</span>
</div>
<Check
v-if="
showEditModal
? selectedEditUser?.id === user.id
: selectedUser?.id === user.id
"
:size="14"
class="text-blue-500"
/>
</div>
</div>
</div>
<div
v-if="showEditModal ? selectedEditUser : selectedUser"
class="flex items-center gap-2 mt-2 px-1"
>
<span class="text-[10px] font-bold text-blue-500"
>已选择: {{ (showEditModal ? selectedEditUser : selectedUser).name }}
<template v-if="(showEditModal ? selectedEditUser : selectedUser).username">
(@{{ (showEditModal ? selectedEditUser : selectedUser).username }})
</template></span
>
<button
class="text-zinc-600 hover:text-zinc-400"
@click="showEditModal ? clearSelectedEditUser() : clearSelectedUser()"
>
<X :size="12" />
</button>
</div>
</div>
<div v-if="showEditModal" class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>联合投稿人</label
>
<div class="space-y-3 user-search-container">
<div
v-if="selectedEditCollaborators.length > 0"
class="flex flex-wrap gap-2"
>
<span
v-for="collaborator in selectedEditCollaborators"
:key="collaborator.id"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-950 border border-zinc-800 text-xs text-zinc-300"
>
{{ getCollaboratorDisplayName(collaborator) }}
<button
class="text-zinc-500 hover:text-red-400 transition-colors"
@click="removeEditCollaborator(collaborator.id)"
>
<X :size="12" />
</button>
</span>
</div>
<div class="relative">
<input
v-model="editCollaboratorSearchQuery"
type="text"
placeholder="搜索并添加联合投稿人"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
@focus="showEditCollaboratorDropdown = true"
@input="searchEditCollaborators()"
>
<div
v-if="editCollaboratorSearchLoading"
class="absolute right-4 top-1/2 -translate-y-1/2"
>
<div
class="w-4 h-4 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
/>
</div>
<div
v-if="showEditCollaboratorDropdown && filteredEditCollaborators.length > 0"
class="absolute z-10 w-full mt-2 bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl overflow-hidden max-h-48 overflow-y-auto"
>
<div
v-for="user in filteredEditCollaborators.slice(0, 10)"
:key="user.id"
class="px-4 py-3 hover:bg-zinc-800 cursor-pointer transition-colors flex items-center justify-between group"
@click="selectEditCollaborator(user)"
>
<div class="flex flex-col">
<span class="text-sm font-bold text-zinc-200">{{ user.name }}</span>
<span class="text-[10px] text-zinc-500">@{{ user.username }}</span>
</div>
<Plus :size="14" class="text-blue-500 opacity-70 group-hover:opacity-100" />
</div>
</div>
</div>
<p class="text-[10px] text-zinc-600 font-medium px-1">
支持添加多个联合投稿人,保存后立即生效
</p>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>学期</label
>
<CustomSelect
v-if="showEditModal"
v-model="editForm.semester"
:options="availableSemesters"
label-key="name"
value-key="name"
placeholder="选择学期"
/>
<CustomSelect
v-else
v-model="addForm.semester"
:options="availableSemesters"
label-key="name"
value-key="name"
placeholder="选择学期"
/>
</div>
<div v-if="showEditModal" class="space-y-3">
<div class="flex items-center justify-between gap-3 px-1">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>歌曲备注</label
>
<button
:disabled="!canClearEditSubmissionNote && !submissionNoteClearRequested"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-[10px] font-black tracking-widest uppercase transition-all disabled:opacity-40 disabled:cursor-not-allowed"
:class="
submissionNoteClearRequested
? 'border-zinc-700 bg-zinc-950 text-zinc-300 hover:border-zinc-600'
: 'border-amber-500/20 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20'
"
@click="
submissionNoteClearRequested
? cancelClearSubmissionNote()
: requestClearSubmissionNote()
"
>
{{ submissionNoteClearRequested ? '撤销清空' : '清空备注' }}
</button>
</div>
<textarea
v-model="editForm.submissionNote"
:disabled="submissionNoteClearRequested"
placeholder="填写歌曲备注"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 min-h-[120px] resize-none transition-all disabled:opacity-50 disabled:cursor-not-allowed"
/>
<div
v-if="submissionNoteClearRequested"
class="space-y-3 rounded-xl border border-amber-500/20 bg-amber-500/5 p-4"
>
<div class="text-xs font-bold text-amber-300">
保存后将清空当前备注,可填写理由并发送通知
</div>
<textarea
v-model="submissionNoteClearReason"
placeholder="可选:请输入清空备注的理由,例如:备注内容违规"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-amber-500/30 min-h-[96px] resize-none transition-all"
/>
<label class="flex items-center gap-3 cursor-pointer group">
<input
v-model="notifyOnSubmissionNoteClear"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-950 accent-amber-500"
>
<div>
<span class="text-xs font-bold text-zinc-300 group-hover:text-amber-300 transition-colors"
>清空后发送通知</span
>
<p class="text-[10px] text-zinc-600 font-medium">
将通知主投稿人和当前联合投稿人
</p>
</div>
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 pt-4 border-t border-zinc-800/50">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>音乐平台 (可选)</label
>
<CustomSelect
v-if="showEditModal"
v-model="editForm.musicPlatform"
:options="[
{ label: '网易云音乐', value: 'netease' },
{ label: 'QQ音乐', value: 'tencent' },
{ label: '哔哩哔哩', value: 'bilibili' }
]"
placeholder="选择平台"
/>
<CustomSelect
v-else
v-model="addForm.musicPlatform"
:options="[
{ label: '网易云音乐', value: 'netease' },
{ label: 'QQ音乐', value: 'tencent' },
{ label: '哔哩哔哩', value: 'bilibili' }
]"
placeholder="选择平台"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>音乐ID (可选)</label
>
<input
v-if="showEditModal"
v-model="editForm.musicId"
type="text"
placeholder="输入平台唯一标识符"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none transition-all"
>
<input
v-else
v-model="addForm.musicId"
type="text"
placeholder="输入平台唯一标识符"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none transition-all"
>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>封面URL (可选)</label
>
<input
v-if="showEditModal"
v-model="editForm.cover"
type="text"
placeholder="http://..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none transition-all"
>
<input
v-else
v-model="addForm.cover"
type="text"
placeholder="http://..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none transition-all"
>
<p
v-if="
(showEditModal ? editCoverValidation.valid : addCoverValidation.valid) &&
(showEditModal ? editForm.cover : addForm.cover)
"
class="text-[10px] text-emerald-500/80 font-bold px-1 flex items-center gap-1"
>
<Check :size="10" /> URL有效
</p>
<p
v-if="
!(showEditModal ? editCoverValidation.valid : addCoverValidation.valid) &&
(showEditModal ? editForm.cover : addForm.cover)
"
class="text-[10px] text-red-500/80 font-bold px-1 flex items-center gap-1"
>
<X :size="10" />
{{ showEditModal ? editCoverValidation.error : addCoverValidation.error }}
</p>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>播放地址URL (可选)</label
>
<input
v-if="showEditModal"
v-model="editForm.playUrl"
type="text"
placeholder="http://..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none transition-all"
>
<input
v-else
v-model="addForm.playUrl"
type="text"
placeholder="http://..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none transition-all"
>
<p
v-if="
(showEditModal ? editPlayUrlValidation.valid : addPlayUrlValidation.valid) &&
(showEditModal ? editForm.playUrl : addForm.playUrl)
"
class="text-[10px] text-emerald-500/80 font-bold px-1 flex items-center gap-1"
>
<Check :size="10" /> URL有效
</p>
<p
v-if="
!(showEditModal ? editPlayUrlValidation.valid : addPlayUrlValidation.valid) &&
(showEditModal ? editForm.playUrl : addForm.playUrl)
"
class="text-[10px] text-red-500/80 font-bold px-1 flex items-center gap-1"
>
<X :size="10" />
{{ showEditModal ? editPlayUrlValidation.error : addPlayUrlValidation.error }}
</p>
</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="showEditModal ? cancelEditSong() : cancelAddSong()"
>
取消
</button>
<button
:disabled="showEditModal ? editLoading : !canSubmitAddForm || addLoading"
class="px-8 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-lg transition-all disabled:opacity-50"
@click="showEditModal ? saveEditSong() : saveAddSong()"
>
{{
showEditModal
? editLoading
? '保存中...'
: '保存更改'
: addLoading
? '添加中...'
: '添加歌曲'
}}
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch, onUnmounted } from 'vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import VotersModal from '~/components/Admin/VotersModal.vue'
import SongDownloadDialog from '~/components/Admin/SongDownloadDialog.vue'
import SubmissionRemarkDialog from '~/components/Admin/SubmissionRemarkDialog.vue'
import Pagination from '~/components/UI/Common/Pagination.vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import {
Search,
Plus,
RotateCcw,
Edit2,
Check,
X,
Trash2,
Music,
ListMusic,
Heart,
Download,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ExternalLink,
MessageSquare,
CalendarDays
} from '@lucide/vue'
import { useSongs } from '~/composables/useSongs'
import { useAdmin } from '~/composables/useAdmin'
import { useAuth } from '~/composables/useAuth'
import { useToast } from '~/composables/useToast'
import { useSemesters } from '~/composables/useSemesters'
import { useSongPlayer } from '~/composables/useSongPlayer'
import { validateUrl, convertToHttps } from '~/utils/url'
// 响应式数据
const { showToast: showNotification } = useToast()
const loading = ref(false)
const searchQuery = ref('')
const statusFilter = ref('all')
const sortOption = ref('time-desc')
const selectedSongs = ref([])
const currentPage = ref(1)
const pageSize = ref(20)
// 音频播放器
const { playSong } = useSongPlayer()
// 学期相关
const selectedSemester = ref('all')
const availableSemesters = ref([])
// 选项配置
const statusOptions = [
{ label: '全部状态', value: 'all' },
{ label: '待排期', value: 'pending' },
{ label: '已排期', value: 'scheduled' },
{ label: '已播放', value: 'played' },
{ label: '有备注', value: 'has-note' }
]
const sortOptions = [
{ label: '最新投稿', value: 'time-desc' },
{ label: '最早投稿', value: 'time-asc' },
{ label: '热度最高', value: 'votes-desc' },
{ label: '热度最低', value: 'votes-asc' },
{ label: '标题 A-Z', value: 'title-asc' },
{ label: '标题 Z-A', value: 'title-desc' }
]
// 删除对话框相关
const showDeleteDialog = ref(false)
const deleteDialogTitle = ref('')
const deleteDialogMessage = ref('')
const deleteAction = ref(null)
// 投票人员弹窗相关
const showVotersModal = ref(false)
const selectedSongId = ref(null)
// 下载对话框相关
const showDownloadDialog = ref(false)
const selectedSongsForDownload = ref([])
const submissionRemarkDialog = ref({
show: false,
songTitle: '',
content: '',
isPublic: false
})
// 驳回歌曲相关
const showRejectDialog = ref(false)
const rejectLoading = ref(false)
const rejectReason = ref('')
const addToBlacklist = ref(false)
const isBatchReject = ref(false)
const rejectSongInfo = ref({
id: null,
title: '',
artist: '',
requester: ''
})
// 编辑歌曲相关
const showEditModal = ref(false)
const editLoading = ref(false)
const editForm = ref({
id: null,
title: '',
artist: '',
requester: '',
semester: '',
submissionNote: '',
musicPlatform: '',
musicId: '',
cover: '',
playUrl: ''
})
const originalEditSubmissionNote = ref('')
const submissionNoteClearRequested = ref(false)
const submissionNoteClearReason = ref('')
const notifyOnSubmissionNoteClear = ref(true)
// 添加歌曲相关
const showAddSongModal = ref(false)
const addLoading = ref(false)
const searchLoading = ref(false)
const addForm = ref({
title: '',
artist: '',
requester: '',
semester: '',
musicPlatform: '',
musicId: '',
cover: '',
playUrl: ''
})
// URL验证状态
const addCoverValidation = ref({ valid: true, error: '', validating: false })
const addPlayUrlValidation = ref({ valid: true, error: '', validating: false })
const editCoverValidation = ref({ valid: true, error: '', validating: false })
const editPlayUrlValidation = ref({ valid: true, error: '', validating: false })
// 用户搜索相关
const userSearchQuery = ref('')
const showUserDropdown = ref(false)
const selectedUser = ref(null)
const filteredUsers = ref([])
const userSearchLoading = ref(false)
// 编辑模态框的用户搜索
const editUserSearchQuery = ref('')
const showEditUserDropdown = ref(false)
const selectedEditUser = ref(null)
const filteredEditUsers = ref([])
const editUserSearchLoading = ref(false)
// 联合投稿人搜索相关
const selectedEditCollaborators = ref([])
const editCollaboratorSearchQuery = ref('')
const filteredEditCollaborators = ref([])
const showEditCollaboratorDropdown = ref(false)
const editCollaboratorSearchLoading = ref(false)
let editCollaboratorSearchTimeout = null
// 数据
const songs = ref([])
// 服务
let songsService = null
let adminService = null
let auth = null
// 计算属性
const filteredSongs = computed(() => {
if (!songs.value) return []
let filtered = [...songs.value]
// 搜索过滤
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(
(song) =>
song.title?.toLowerCase().includes(query) ||
song.artist?.toLowerCase().includes(query) ||
song.requester?.toLowerCase().includes(query)
)
}
// 学期过滤
if (selectedSemester.value && selectedSemester.value !== 'all') {
filtered = filtered.filter((song) => song.semester === selectedSemester.value)
}
// 状态过滤
if (statusFilter.value !== 'all') {
filtered = filtered.filter((song) => {
switch (statusFilter.value) {
case 'pending':
// 待排期:未播放且未排期
return !song.played && !song.scheduled
case 'scheduled':
// 已排期:未播放但已排期
return !song.played && song.scheduled
case 'played':
// 已播放
return song.played
case 'has-note':
return Boolean(song.submissionNote && song.submissionNote.trim())
default:
return true
}
})
}
// 排序
filtered.sort((a, b) => {
switch (sortOption.value) {
case 'time-desc':
return new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
case 'time-asc':
return new Date(a.createdAt || 0) - new Date(b.createdAt || 0)
case 'votes-desc':
return (b.voteCount || 0) - (a.voteCount || 0)
case 'votes-asc':
return (a.voteCount || 0) - (b.voteCount || 0)
case 'title-asc':
return (a.title || '').localeCompare(b.title || '')
case 'title-desc':
return (b.title || '').localeCompare(a.title || '')
default:
return 0
}
})
return filtered
})
const paginatedSongs = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredSongs.value.slice(start, end)
})
const totalPages = computed(() => {
return Math.ceil(filteredSongs.value.length / pageSize.value)
})
// 显示的分页按钮
const playedCount = computed(() => {
return songs.value.filter((song) => song.played).length
})
const pendingCount = computed(() => {
return songs.value.filter((song) => !song.played).length
})
const isAllSelected = computed(() => {
return (
paginatedSongs.value.length > 0 &&
paginatedSongs.value.every((song) => selectedSongs.value.includes(song.id))
)
})
// 计算属性:检查添加歌曲表单是否可以提交
const canSubmitAddForm = computed(() => {
// 必填字段检查
if (
!addForm.value.title.trim() ||
!addForm.value.artist.trim() ||
!selectedUser.value ||
!addForm.value.semester
) {
return false
}
// 可选字段验证检查
if (
addForm.value.cover &&
(!addCoverValidation.value.valid || addCoverValidation.value.validating)
) {
return false
}
if (
addForm.value.playUrl &&
(!addPlayUrlValidation.value.valid || addPlayUrlValidation.value.validating)
) {
return false
}
return true
})
const canClearEditSubmissionNote = computed(() => {
return Boolean(originalEditSubmissionNote.value || editForm.value.submissionNote)
})
// 方法
const formatDate = (dateString) => {
const date = new Date(dateString)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
return `${Math.floor(diff / 86400000)}天前`
}
const getStatusText = (song) => {
if (song.played) return '已播放'
if (song.scheduled) return '待播放'
return '未排期'
}
const getCollaboratorDisplayName = (user) => {
return user?.displayName || user?.name || user?.username || '未知用户'
}
const openSubmissionRemark = (song) => {
if (!song?.submissionNote) return
submissionRemarkDialog.value = {
show: true,
songTitle: `${song.title} - ${song.artist}`,
content: song.submissionNote,
isPublic: song.submissionNotePublic === true
}
}
const toggleSelectAll = () => {
if (isAllSelected.value) {
selectedSongs.value = selectedSongs.value.filter(
(id) => !paginatedSongs.value.some((song) => song.id === id)
)
} else {
const newSelections = paginatedSongs.value
.map((song) => song.id)
.filter((id) => !selectedSongs.value.includes(id))
selectedSongs.value.push(...newSelections)
}
}
const toggleSelectSong = (songId) => {
const index = selectedSongs.value.indexOf(songId)
if (index > -1) {
selectedSongs.value.splice(index, 1)
} else {
selectedSongs.value.push(songId)
}
}
const refreshSongs = async (bypassCache = false, silent = false) => {
if (!silent) loading.value = true
try {
await songsService.fetchSongs(false, undefined, true, bypassCache)
songs.value = songsService.songs.value || []
if (!silent) selectedSongs.value = []
} catch (error) {
console.error('刷新歌曲失败:', error)
} finally {
if (!silent) loading.value = false
}
}
const markAsPlayed = async (songId) => {
try {
await songsService.markPlayed(songId)
await refreshSongs()
} catch (error) {
console.error('标记已播放失败:', error)
showNotification('标记失败: ' + error.message, 'error')
}
}
const markAsUnplayed = async (songId) => {
try {
await songsService.unmarkPlayed(songId)
await refreshSongs()
} catch (error) {
console.error('标记未播放失败:', error)
showNotification('标记失败: ' + error.message, 'error')
}
}
const batchMarkPlayed = async () => {
if (selectedSongs.value.length === 0) return
try {
loading.value = true
await $fetch('/api/admin/songs/mark-played', {
method: 'POST',
body: { songIds: selectedSongs.value },
...auth.getAuthConfig()
})
await refreshSongs()
selectedSongs.value = []
showNotification('批量标记已播放成功', 'success')
} catch (error) {
console.error('批量标记已播放失败:', error)
showNotification('批量标记失败: ' + (error.data?.message || error.message), 'error')
} finally {
loading.value = false
}
}
const batchMarkUnplayed = async () => {
if (selectedSongs.value.length === 0) return
try {
loading.value = true
await $fetch('/api/admin/songs/mark-played', {
method: 'POST',
body: { songIds: selectedSongs.value, unmark: true },
...auth.getAuthConfig()
})
await refreshSongs()
selectedSongs.value = []
showNotification('批量撤回已播放成功', 'success')
} catch (error) {
console.error('批量撤回已播放失败:', error)
showNotification('批量撤回失败: ' + (error.data?.message || error.message), 'error')
} finally {
loading.value = false
}
}
const deleteSong = async (songId) => {
const song = songs.value.find((s) => s.id === songId)
if (!song) return
deleteDialogTitle.value = '删除歌曲'
deleteDialogMessage.value = `确定要删除歌曲 "${song.title}" 吗?此操作不可撤销。`
deleteAction.value = async () => {
try {
await adminService.deleteSong(songId)
await refreshSongs()
const index = selectedSongs.value.indexOf(songId)
if (index > -1) {
selectedSongs.value.splice(index, 1)
}
showNotification('歌曲删除成功', 'success')
} catch (error) {
console.error('删除歌曲失败:', error)
showNotification('删除失败: ' + error.message, 'error')
}
}
showDeleteDialog.value = true
}
const batchDelete = async () => {
if (selectedSongs.value.length === 0) return
deleteDialogTitle.value = '批量删除歌曲'
deleteDialogMessage.value = `确定要删除选中的 ${selectedSongs.value.length} 首歌曲吗?此操作不可撤销。`
deleteAction.value = async () => {
try {
loading.value = true
for (const songId of selectedSongs.value) {
await adminService.deleteSong(songId)
}
await refreshSongs()
selectedSongs.value = []
showNotification('批量删除成功', 'success')
} catch (error) {
console.error('批量删除失败:', error)
showNotification('批量删除失败: ' + error.message, 'error')
} finally {
loading.value = false
}
}
showDeleteDialog.value = true
}
// 显示投票人员弹窗
const showVoters = (songId) => {
selectedSongId.value = songId
showVotersModal.value = true
}
// 关闭投票人员弹窗
const closeVotersModal = () => {
showVotersModal.value = false
selectedSongId.value = null
}
// 打开下载对话框
const openDownloadDialog = () => {
selectedSongsForDownload.value = selectedSongs.value.map((songId) => {
const song = songs.value.find((s) => s.id === songId)
return {
id: `temp-${song.id}`,
song: {
id: song.id,
title: song.title,
artist: song.artist,
musicPlatform: song.musicPlatform || 'unknown',
requester: song.requester || '未知',
musicId: song.musicId,
playUrl: song.playUrl
}
}
})
showDownloadDialog.value = true
}
// 关闭下载对话框
const closeDownloadDialog = () => {
showDownloadDialog.value = false
selectedSongsForDownload.value = []
}
// 确认删除
const confirmDelete = async () => {
if (deleteAction.value) {
await deleteAction.value()
}
showDeleteDialog.value = false
deleteAction.value = null
}
// 驳回歌曲
const rejectSong = (songId) => {
const song = songs.value.find((s) => s.id === songId)
if (!song) return
isBatchReject.value = false
rejectSongInfo.value = {
id: song.id,
title: song.title || '',
artist: song.artist || '',
requester: song.requester || song.requester_name || '未知'
}
rejectReason.value = ''
addToBlacklist.value = false
showRejectDialog.value = true
}
// 批量驳回歌曲
const batchReject = () => {
if (selectedSongs.value.length === 0) return
isBatchReject.value = true
rejectSongInfo.value = {
id: null,
title: '',
artist: '',
requester: ''
}
rejectReason.value = ''
addToBlacklist.value = false
showRejectDialog.value = true
}
// 确认驳回
const confirmReject = async () => {
if (!rejectReason.value.trim()) {
showNotification('请填写驳回原因', 'error')
return
}
rejectLoading.value = true
try {
if (isBatchReject.value) {
await $fetch('/api/admin/songs/batch-reject', {
method: 'POST',
body: {
songIds: selectedSongs.value,
reason: rejectReason.value.trim(),
addToBlacklist: addToBlacklist.value
}
})
await refreshSongs(true)
selectedSongs.value = []
showNotification(`批量驳回成功,共处理 ${selectedSongs.value.length} 首歌曲`, 'success')
} else {
await $fetch('/api/admin/songs/reject', {
method: 'POST',
body: {
songId: rejectSongInfo.value.id,
reason: rejectReason.value.trim(),
addToBlacklist: addToBlacklist.value
}
})
await refreshSongs(true)
const index = selectedSongs.value.indexOf(rejectSongInfo.value.id)
if (index > -1) {
selectedSongs.value.splice(index, 1)
}
showNotification('歌曲驳回成功,已通知投稿人', 'success')
}
showRejectDialog.value = false
} catch (error: any) {
console.error('驳回歌曲失败:', error)
showNotification('驳回失败: ' + (error.data?.message || error.message), 'error')
} finally {
rejectLoading.value = false
}
}
// 取消驳回
const cancelReject = () => {
showRejectDialog.value = false
rejectReason.value = ''
addToBlacklist.value = false
isBatchReject.value = false
rejectSongInfo.value = {
id: null,
title: '',
artist: '',
requester: ''
}
}
// 编辑歌曲
const editSong = (song) => {
editForm.value = {
id: song.id,
title: song.title || '',
artist: song.artist || '',
requester: song.requesterId || song.requester_id || song.requester || '',
semester: song.semester || '',
submissionNote: song.submissionNote || '',
musicPlatform: song.musicPlatform || '',
musicId: song.musicId || '',
cover: song.cover || '',
playUrl: song.playUrl || ''
}
originalEditSubmissionNote.value = song.submissionNote || ''
submissionNoteClearRequested.value = false
submissionNoteClearReason.value = ''
notifyOnSubmissionNoteClear.value = true
if (song.requesterId || song.requester_id || song.requester_name || song.requester) {
selectedEditUser.value = {
id: song.requesterId || song.requester_id || song.requester,
name: song.requester_name || song.requester || '未知',
username: song.requester_username || song.user?.username || song.requester?.username || ''
}
editUserSearchQuery.value = song.requester_name || song.requester || ''
} else {
clearSelectedEditUser()
}
if (song.collaborators && Array.isArray(song.collaborators)) {
selectedEditCollaborators.value = [...song.collaborators]
} else {
selectedEditCollaborators.value = []
}
showEditModal.value = true
}
const saveEditSong = async () => {
if (!editForm.value.title || !editForm.value.artist) {
showNotification('请填写歌曲名称和歌手', 'error')
return
}
if (editForm.value.cover && !editCoverValidation.value.valid) {
showNotification('请等待封面URL验证完成或修正无效的URL', 'error')
return
}
if (editForm.value.playUrl && !editPlayUrlValidation.value.valid) {
showNotification('请等待播放URL验证完成或修正无效的URL', 'error')
return
}
if (editCoverValidation.value.validating || editPlayUrlValidation.value.validating) {
showNotification('正在验证URL,请稍候...', 'warning')
return
}
editLoading.value = true
try {
const { updateSong } = adminService
await updateSong(editForm.value.id, {
title: editForm.value.title,
artist: editForm.value.artist,
requester: editForm.value.requester,
collaborators: selectedEditCollaborators.value.map((u) => u.id),
semester: editForm.value.semester,
submissionNote: submissionNoteClearRequested.value ? null : editForm.value.submissionNote,
clearSubmissionNote: submissionNoteClearRequested.value,
submissionNoteClearReason: submissionNoteClearReason.value.trim(),
notifyOnSubmissionNoteClear: submissionNoteClearRequested.value && notifyOnSubmissionNoteClear.value,
musicPlatform: editForm.value.musicPlatform || null,
musicId: editForm.value.musicId || null,
cover: editForm.value.cover || null,
playUrl: editForm.value.playUrl || null
})
await refreshSongs()
showEditModal.value = false
showNotification('歌曲信息更新成功', 'success')
} catch (error) {
console.error('更新歌曲失败:', error)
let errorMessage = '更新失败'
if (error.data && error.data.message) {
errorMessage = error.data.message
} else if (error.message) {
errorMessage = error.message
}
showNotification(errorMessage, 'error')
} finally {
editLoading.value = false
}
}
const cancelEditSong = () => {
showEditModal.value = false
editForm.value = {
id: null,
title: '',
artist: '',
requester: '',
semester: '',
submissionNote: '',
musicPlatform: '',
musicId: '',
cover: '',
playUrl: ''
}
originalEditSubmissionNote.value = ''
submissionNoteClearRequested.value = false
submissionNoteClearReason.value = ''
notifyOnSubmissionNoteClear.value = true
editCoverValidation.value = { valid: true, error: '', validating: false }
editPlayUrlValidation.value = { valid: true, error: '', validating: false }
clearSelectedEditUser()
selectedEditCollaborators.value = []
editCollaboratorSearchQuery.value = ''
}
const requestClearSubmissionNote = () => {
if (!canClearEditSubmissionNote.value) {
return
}
submissionNoteClearRequested.value = true
submissionNoteClearReason.value = ''
notifyOnSubmissionNoteClear.value = true
}
const cancelClearSubmissionNote = () => {
submissionNoteClearRequested.value = false
submissionNoteClearReason.value = ''
notifyOnSubmissionNoteClear.value = true
}
const openAddSongModal = () => {
addForm.value = {
title: '',
artist: '',
requester: '',
semester: selectedSemester.value !== 'all' ? selectedSemester.value : '',
musicPlatform: '',
musicId: '',
cover: ''
}
showAddSongModal.value = true
}
const saveAddSong = async () => {
if (!addForm.value.title || !addForm.value.artist) {
showNotification('请填写歌曲名称和歌手', 'error')
return
}
if (!selectedUser.value || !addForm.value.requester) {
showNotification('请选择投稿人', 'error')
return
}
if (!addForm.value.semester) {
showNotification('请选择学期', 'error')
return
}
if (addForm.value.cover) {
if (!addCoverValidation.value.valid) {
showNotification('歌曲封面URL无效,请检查后重试', 'error')
return
}
if (addCoverValidation.value.validating) {
showNotification('正在验证封面URL,请稍候...', 'warning')
return
}
}
if (addForm.value.playUrl) {
if (!addPlayUrlValidation.value.valid) {
showNotification('播放地址URL无效,请检查后重试', 'error')
return
}
if (addPlayUrlValidation.value.validating) {
showNotification('正在验证播放地址URL,请稍候...', 'warning')
return
}
}
addLoading.value = true
try {
const { addSong } = adminService
await addSong({
title: addForm.value.title,
artist: addForm.value.artist,
requester: addForm.value.requester,
semester: addForm.value.semester,
musicPlatform: addForm.value.musicPlatform || null,
musicId: addForm.value.musicId || null,
cover: addForm.value.cover || null,
playUrl: addForm.value.playUrl || null
})
await refreshSongs()
showAddSongModal.value = false
addForm.value = {
title: '',
artist: '',
requester: '',
semester: '',
musicPlatform: '',
musicId: '',
cover: '',
playUrl: ''
}
clearSelectedUser()
showNotification('歌曲添加成功', 'success')
} catch (error) {
console.error('添加歌曲失败:', error)
let errorMessage = '添加失败'
if (error.data && error.data.message) {
errorMessage = error.data.message
} else if (error.message) {
errorMessage = error.message
}
showNotification(errorMessage, 'error')
} finally {
addLoading.value = false
}
}
const cancelAddSong = () => {
showAddSongModal.value = false
addForm.value = {
title: '',
artist: '',
requester: '',
semester: '',
musicPlatform: '',
musicId: '',
cover: '',
playUrl: ''
}
addCoverValidation.value = { valid: true, error: '', validating: false }
addPlayUrlValidation.value = { valid: true, error: '', validating: false }
clearSelectedUser()
}
// 用户搜索功能
let searchTimeout = null
const searchUsersFromAPI = async (query) => {
if (!query.trim()) {
return []
}
try {
const response = await $fetch('/api/admin/users', {
method: 'GET',
query: {
search: query,
limit: 20
}
})
return response.users || []
} catch (error) {
console.error('搜索用户失败:', error)
return []
}
}
const searchUsers = async () => {
if (!userSearchQuery.value.trim()) {
filteredUsers.value = []
showUserDropdown.value = false
userSearchLoading.value = false
return
}
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(async () => {
userSearchLoading.value = true
try {
const users = await searchUsersFromAPI(userSearchQuery.value)
filteredUsers.value = users
showUserDropdown.value = users.length > 0
} catch (error) {
console.error('搜索用户失败:', error)
filteredUsers.value = []
showUserDropdown.value = false
} finally {
userSearchLoading.value = false
}
}, 300)
}
const selectUser = (user) => {
selectedUser.value = user
addForm.value.requester = user.id
userSearchQuery.value = user.name
showUserDropdown.value = false
}
const clearSelectedUser = () => {
selectedUser.value = null
addForm.value.requester = ''
userSearchQuery.value = ''
showUserDropdown.value = false
}
// 编辑模态框的用户搜索功能
let editSearchTimeout = null
const searchEditUsers = async () => {
if (!editUserSearchQuery.value.trim()) {
filteredEditUsers.value = []
showEditUserDropdown.value = false
editUserSearchLoading.value = false
return
}
if (editSearchTimeout) {
clearTimeout(editSearchTimeout)
}
editSearchTimeout = setTimeout(async () => {
editUserSearchLoading.value = true
try {
const users = await searchUsersFromAPI(editUserSearchQuery.value)
filteredEditUsers.value = users
showEditUserDropdown.value = users.length > 0
} catch (error) {
console.error('搜索用户失败:', error)
filteredEditUsers.value = []
showEditUserDropdown.value = false
} finally {
editUserSearchLoading.value = false
}
}, 300)
}
const selectEditUser = (user) => {
selectedEditUser.value = user
editForm.value.requester = user.id
editUserSearchQuery.value = user.name
showEditUserDropdown.value = false
}
const clearSelectedEditUser = () => {
selectedEditUser.value = null
editForm.value.requester = ''
editUserSearchQuery.value = ''
showEditUserDropdown.value = false
}
// 联合投稿人搜索相关
const searchEditCollaborators = async () => {
if (!editCollaboratorSearchQuery.value.trim()) {
filteredEditCollaborators.value = []
showEditCollaboratorDropdown.value = false
editCollaboratorSearchLoading.value = false
return
}
if (editCollaboratorSearchTimeout) {
clearTimeout(editCollaboratorSearchTimeout)
}
editCollaboratorSearchTimeout = setTimeout(async () => {
editCollaboratorSearchLoading.value = true
try {
const users = await searchUsersFromAPI(editCollaboratorSearchQuery.value)
filteredEditCollaborators.value = users.filter(
(u) =>
(!selectedEditUser.value || u.id !== selectedEditUser.value.id) &&
!selectedEditCollaborators.value.some((c) => c.id === u.id)
)
showEditCollaboratorDropdown.value = filteredEditCollaborators.value.length > 0
} catch (error) {
console.error('搜索联合投稿人失败:', error)
filteredEditCollaborators.value = []
showEditCollaboratorDropdown.value = false
} finally {
editCollaboratorSearchLoading.value = false
}
}, 300)
}
const selectEditCollaborator = (user) => {
selectedEditCollaborators.value.push(user)
editCollaboratorSearchQuery.value = ''
showEditCollaboratorDropdown.value = false
}
const removeEditCollaborator = (userId) => {
const index = selectedEditCollaborators.value.findIndex((u) => u.id === userId)
if (index > -1) {
selectedEditCollaborators.value.splice(index, 1)
}
}
// 点击外部关闭下拉框
const handleClickOutside = (event) => {
if (!event.target.closest('.user-search-container')) {
showUserDropdown.value = false
showEditUserDropdown.value = false
showEditCollaboratorDropdown.value = false
}
}
// 监听器
watch([searchQuery, statusFilter, sortOption, selectedSemester], () => {
currentPage.value = 1
})
// 生命周期
const { semesters, fetchSemesters, semesterUpdateEvent } = useSemesters()
watch(semesterUpdateEvent, async () => {
await fetchSemesters()
availableSemesters.value = semesters.value || []
})
let refreshInterval
onMounted(async () => {
songsService = useSongs()
adminService = useAdmin()
auth = useAuth()
const { fetchCurrentSemester, currentSemester } = useSemesters()
await fetchSemesters()
await fetchCurrentSemester()
availableSemesters.value = semesters.value || []
availableSemesters.value.unshift({ id: 'all', name: '全部学期' })
if (currentSemester.value) {
selectedSemester.value = currentSemester.value.name
}
document.addEventListener('click', handleClickOutside)
await refreshSongs()
refreshInterval = setInterval(() => {
refreshSongs(false, true)
}, 10000)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
if (refreshInterval) clearInterval(refreshInterval)
})
// URL验证函数
const validateAddCoverUrl = async (url) => {
if (!url) {
addCoverValidation.value = { valid: true, error: '', validating: false }
return
}
addCoverValidation.value.validating = true
const result = validateUrl(url)
addCoverValidation.value = {
valid: result.valid,
error: result.error || '',
validating: false
}
}
const validateAddPlayUrl = async (url) => {
if (!url) {
addPlayUrlValidation.value = { valid: true, error: '', validating: false }
return
}
addPlayUrlValidation.value.validating = true
const result = validateUrl(url)
addPlayUrlValidation.value = {
valid: result.valid,
error: result.error || '',
validating: false
}
}
const validateEditCoverUrl = async (url) => {
if (!url) {
editCoverValidation.value = { valid: true, error: '', validating: false }
return
}
editCoverValidation.value.validating = true
const result = validateUrl(url)
editCoverValidation.value = {
valid: result.valid,
error: result.error || '',
validating: false
}
}
const validateEditPlayUrl = async (url) => {
if (!url) {
editPlayUrlValidation.value = { valid: true, error: '', validating: false }
return
}
editPlayUrlValidation.value.validating = true
const result = validateUrl(url)
editPlayUrlValidation.value = {
valid: result.valid,
error: result.error || '',
validating: false
}
}
// 监听URL变化并验证
watch(
() => addForm.value.cover,
(newUrl) => {
if (newUrl) {
clearTimeout(addCoverValidation.value.debounceTimer)
addCoverValidation.value.debounceTimer = setTimeout(() => {
validateAddCoverUrl(newUrl)
}, 1000)
} else {
addCoverValidation.value = { valid: true, error: '', validating: false }
}
}
)
watch(
() => addForm.value.playUrl,
(newUrl) => {
if (newUrl) {
clearTimeout(addPlayUrlValidation.value.debounceTimer)
addPlayUrlValidation.value.debounceTimer = setTimeout(() => {
validateAddPlayUrl(newUrl)
}, 1000)
} else {
addPlayUrlValidation.value = { valid: true, error: '', validating: false }
}
}
)
watch(
() => editForm.value.cover,
(newUrl) => {
if (newUrl) {
clearTimeout(editCoverValidation.value.debounceTimer)
editCoverValidation.value.debounceTimer = setTimeout(() => {
validateEditCoverUrl(newUrl)
}, 1000)
} else {
editCoverValidation.value = { valid: true, error: '', validating: false }
}
}
)
watch(
() => editForm.value.playUrl,
(newUrl) => {
if (newUrl) {
clearTimeout(editPlayUrlValidation.value.debounceTimer)
editPlayUrlValidation.value.debounceTimer = setTimeout(() => {
validateEditPlayUrl(newUrl)
}, 1000)
} else {
editPlayUrlValidation.value = { valid: true, error: '', validating: false }
}
}
)
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
</style>