Files
BYW-VoiceHub/app/components/Admin/SongManagement.vue
T

2551 lines
92 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-text-primary tracking-tight">{{ locale.title }}</h2>
<div class="flex items-center gap-6 mt-4">
<div class="flex items-center gap-2">
<span class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
>{{ locale.stats.total }}</span
>
<span class="text-sm font-bold text-text-secondary">{{ baseFilteredSongs.length }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
>{{ locale.stats.played }}</span
>
<span class="text-sm font-bold text-success">{{ playedCount }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
>{{ locale.stats.pending }}</span
>
<span class="text-sm font-bold text-primary">{{ pendingCount }}</span>
</div>
</div>
</div>
<div class="flex flex-wrap 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="hidden sm:flex flex-row items-center gap-2 p-1.5 bg-bg-secondary-90 border border-border-secondary rounded-lg backdrop-blur-xl shadow-2xl"
>
<button
class="flex items-center justify-center gap-2 px-4 py-2 bg-bg-tertiary hover:bg-bg-quaternary text-success text-[11px] font-black rounded-lg border border-border-tertiary-50 transition-all active:scale-95 uppercase tracking-widest"
@click="openDownloadDialog"
>
<Download :size="14" class="text-success-70" /> {{ locale.actions.download }} ({{
selectedSongs.length
}})
</button>
<button
class="flex items-center justify-center gap-2 px-4 py-2 bg-bg-tertiary hover:bg-bg-quaternary text-error text-[11px] font-black rounded-lg border border-border-tertiary-50 transition-all active:scale-95 uppercase tracking-widest"
@click="batchDelete"
>
<Trash2 :size="14" class="text-error-70" /> {{ locale.actions.delete }} ({{ selectedSongs.length }})
</button>
</div>
</Transition>
<button
class="flex items-center gap-2 px-5 py-2.5 bg-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-secondary text-xs font-bold rounded-lg transition-all active:scale-95"
@click="openAddSongModal"
>
<Plus :size="16" /> {{ locale.actions.addManual }}
</button>
<button
:disabled="loading"
class="flex items-center gap-2 px-5 py-2.5 bg-primary-hover-10 hover:bg-primary-hover-20 text-primary text-xs font-bold rounded-lg border border-primary-20 transition-all disabled:opacity-50"
@click="refreshSongs(true)"
>
<RotateCcw :size="16" :class="{ 'animate-spin': loading }" /> {{ locale.actions.refresh }}
</button>
</div>
</div>
<!-- 过滤器栏 -->
<div
class="bg-bg-secondary-30 border border-border-secondary-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-text-secondary group-focus-within:text-primary transition-colors"
:size="18"
/>
<input
v-model="searchQuery"
type="text"
:placeholder="locale.searchPlaceholder"
class="w-full bg-bg-primary-50 border border-border-secondary-80 rounded-lg pl-12 pr-4 py-3 text-sm focus:outline-none focus:border-primary-30 transition-all placeholder:text-text-primary text-text-primary"
/>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:flex lg:items-center gap-3 w-full lg:w-auto">
<CustomSelect
v-model="selectedSemester"
:label="locale.filters.semester"
:options="availableSemesters"
label-key="name"
value-key="id"
:placeholder="locale.filters.selectSemester"
class-name="w-full lg:w-40"
/>
<CustomSelect
v-model="selectedPlayTime"
:label="locale.filters.playTime"
:options="availablePlayTimes"
label-key="name"
value-key="id"
:placeholder="locale.filters.selectPlayTime"
class-name="w-full lg:w-40"
/>
<CustomSelect
v-model="statusFilter"
:label="locale.filters.status"
:options="statusOptions"
:placeholder="locale.filters.selectStatus"
class-name="w-full lg:w-32"
/>
<CustomSelect
v-model="sortOption"
:label="locale.filters.sort"
:options="sortOptions"
:placeholder="locale.filters.selectSort"
class-name="w-full lg:w-36 col-span-2 md:col-span-1"
/>
</div>
</div>
</div>
<!-- 歌曲表格 -->
<div
class="bg-bg-secondary-10 border border-border-secondary-40 rounded-xl overflow-hidden shadow-2xl relative"
>
<!-- 加载遮罩 -->
<div
v-if="loading"
class="absolute inset-0 bg-bg-primary-40 backdrop-blur-sm z-10 flex items-center justify-center"
>
<div class="flex flex-col items-center gap-3">
<AppSpinner :size="32" />
<span class="text-xs font-bold text-text-tertiary tracking-widest uppercase">{{ locale.loading }}</span>
</div>
</div>
<!-- 表头 -->
<div
class="hidden lg:grid grid-cols-12 gap-4 px-8 py-5 border-b border-border-secondary-60 bg-bg-secondary-40"
>
<div class="col-span-1 flex items-center">
<input
type="checkbox"
:checked="isAllSelected"
class="w-5 h-5 rounded-lg border-2 border-border-secondary bg-bg-primary transition-all cursor-pointer"
@change="toggleSelectAll"
/>
</div>
<div class="col-span-4 text-[10px] font-black text-text-disabled uppercase tracking-widest">
{{ locale.table.songInfo }}
</div>
<div class="col-span-2 text-[10px] font-black text-text-disabled uppercase tracking-widest">
{{ locale.table.requester }}
</div>
<div
class="col-span-1 text-[10px] font-black text-text-disabled uppercase tracking-widest text-center"
>
{{ locale.table.stats }}
</div>
<div
class="col-span-2 text-[10px] font-black text-text-disabled uppercase tracking-widest text-center"
>
{{ locale.table.status }}
</div>
<div
class="col-span-2 text-[10px] font-black text-text-disabled uppercase tracking-widest text-right pr-4"
>
{{ locale.table.actions }}
</div>
</div>
<!-- 移动端批量操作栏 -->
<div
v-if="filteredSongs.length > 0"
class="lg:hidden flex items-center justify-between px-6 py-3 border-b border-border-secondary-60 bg-bg-secondary-40"
>
<label class="flex items-center gap-2 text-xs font-bold text-text-secondary cursor-pointer py-1">
<input
type="checkbox"
:checked="isAllSelected"
class="w-5 h-5 rounded-lg border-2 border-border-secondary bg-bg-primary transition-all cursor-pointer"
@change="toggleSelectAll"
>
{{ locale.table.selectPage }}
</label>
<span v-if="selectedSongs.length > 0" class="text-xs text-primary font-bold">{{ getLocaleMessage('selectedItems', selectedSongs.length) }}</span>
</div>
<!-- 空状态 -->
<div
v-if="filteredSongs.length === 0 && !loading"
class="py-20 flex flex-col items-center justify-center text-text-tertiary"
>
<Music :size="48" class="text-text-primary mb-4" />
<p class="text-sm font-bold">{{ searchQuery ? locale.empty.search : locale.empty.default }}</p>
</div>
<!-- 列表内容 -->
<div v-else class="divide-y divide-panel-bg-dark-40">
<div
v-for="song in paginatedSongs"
:key="song.id"
:class="[
'flex flex-col lg:grid lg:grid-cols-12 gap-4 px-6 lg:px-8 py-5 transition-all lg:items-center group relative',
selectedSongs.includes(song.id) ? 'bg-primary-hover-5' : 'hover:bg-bg-tertiary-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-border-secondary bg-bg-primary transition-all cursor-pointer"
@change="toggleSelectSong(song.id)"
/>
</div>
<div class="col-span-12 lg:col-span-4 flex items-center gap-3 lg:gap-4 w-full">
<label
class="lg:hidden flex items-center justify-center p-2 -ml-2 shrink-0 cursor-pointer"
>
<input
type="checkbox"
:checked="selectedSongs.includes(song.id)"
class="w-5 h-5 rounded-lg border-2 border-border-secondary bg-bg-primary transition-all cursor-pointer"
@change="toggleSelectSong(song.id)"
/>
</label>
<div
:class="[
'w-12 h-12 rounded-xl flex items-center justify-center shrink-0 border transition-all overflow-hidden bg-bg-tertiary-40 border-border-tertiary-30 group-hover:border-border-tertiary cursor-pointer hover:opacity-80',
selectedSongs.includes(song.id) ? 'border-primary-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-text-disabled" />
</div>
<div class="min-w-0">
<h4
:class="[
'font-bold transition-colors flex items-center',
selectedSongs.includes(song.id)
? 'text-primary'
: 'text-text-primary group-hover:text-primary'
]"
>
<span
v-if="isBilibiliSong(song)"
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-primary-30 bg-primary-10 text-primary hover:bg-primary-20 transition-all"
:title="locale.actions.viewRemark"
@click.stop="openSubmissionRemark(song)"
>
<MessageSquare :size="12" />
</button>
<span
v-if="song.hasSubmissionNote && song.submissionNote"
class="ml-2 text-xs text-primary-80 truncate max-w-[200px] cursor-pointer hover:text-primary transition-colors font-normal"
:title="locale.actions.viewRemark"
@click.stop="openSubmissionRemark(song)"
>
{{
song.submissionNote.length > 25
? song.submissionNote.substring(0, 25) + '...'
: song.submissionNote
}}
</span>
</h4>
<p class="text-xs text-text-tertiary font-medium truncate mt-0.5 flex items-center gap-1.5">
<span>{{ song.artist }}</span>
<span
v-if="song.durationSeconds"
class="text-text-disabled shrink-0"
>{{ formatDuration(song.durationSeconds) }}</span>
</p>
<span
class="lg:hidden text-[9px] font-black text-text-secondary uppercase tracking-wider mt-1 inline-block"
>{{ formatDate(song.createdAt) }}</span
>
</div>
</div>
<!-- Mobile Bottom Section -->
<div
class="flex flex-wrap items-center justify-between gap-3 w-full lg:contents mt-1 lg:mt-0"
>
<!-- Left side: Requester, Votes, Status -->
<div class="flex items-center gap-3 lg:contents">
<div class="col-span-6 lg:col-span-2 flex flex-col lg:items-start min-w-[60px]">
<span class="text-xs font-bold text-text-tertiary truncate max-w-[100px]">{{ song.requester || locale.unknown }}</span>
<span v-if="song.user" class="text-[10px] font-bold text-text-disabled truncate max-w-[100px]"
>@{{ song.user.username }}</span
>
<span v-if="song.preferredPlayTimeId" class="text-[10px] font-bold text-primary mt-1">
{{ getLocaleMessage('preferredPlayTime', getPlayTimeName(song.preferredPlayTimeId)) }}
</span>
<span
class="hidden lg:inline text-[9px] font-black text-text-secondary uppercase tracking-widest mt-1 opacity-60"
>{{ formatDate(song.createdAt) }}</span
>
</div>
<div class="col-span-3 lg:col-span-1 text-center shrink-0">
<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-color-pink-alpha-10 text-color-pink hover:bg-color-pink-alpha-10 cursor-pointer'
: 'bg-bg-primary-30 text-text-secondary cursor-default'
]"
@click="showVoters(song.id)"
>
<Heart :size="12" :class="song.voteCount > 0 ? 'fill-color-pink-alpha-20' : ''" />
{{ song.voteCount || 0 }}
</button>
</div>
<div class="col-span-3 lg:col-span-2 text-center shrink-0">
<span
:class="[
'px-2 py-0.5 text-[10px] font-black rounded-md uppercase tracking-wider',
song.played
? 'bg-success-10 text-success'
: song.scheduled
? 'bg-primary-10 text-primary'
: 'bg-bg-tertiary text-text-tertiary'
]"
>
{{ getStatusText(song) }}
</span>
</div>
</div>
<!-- Right side: Actions -->
<div
class="col-span-12 lg:col-span-2 flex items-center justify-end gap-1 lg:gap-2 opacity-100 lg:opacity-0 group-hover:opacity-100 transition-all shrink-0 ml-auto"
>
<button
class="p-2 bg-bg-tertiary-50 text-primary hover:bg-primary-hover hover:text-text-primary rounded-xl transition-all border border-border-tertiary-30"
:title="locale.actions.edit"
@click="editSong(song)"
>
<Edit2 :size="14" />
</button>
<button
v-if="!song.played"
class="p-2 bg-bg-tertiary-50 text-success hover:bg-success hover:text-text-primary rounded-xl transition-all border border-border-tertiary-30"
:title="locale.actions.markPlayed"
@click="markAsPlayed(song.id)"
>
<Check :size="14" />
</button>
<button
v-else
class="p-2 bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-quaternary hover:text-text-primary rounded-xl transition-all border border-border-tertiary-30"
:title="locale.actions.markUnplayed"
@click="markAsUnplayed(song.id)"
>
<RotateCcw :size="14" />
</button>
<button
class="p-2 bg-bg-tertiary-50 text-warning hover:bg-warning hover:text-text-primary rounded-xl transition-all border border-border-tertiary-30"
:title="locale.actions.reject"
@click="rejectSong(song.id)"
>
<X :size="14" />
</button>
<button
class="p-2 bg-bg-tertiary-50 text-error hover:bg-error hover:text-text-primary rounded-xl transition-all border border-border-tertiary-30"
:title="locale.actions.deleteSong"
@click="deleteSong(song.id)"
>
<Trash2 :size="14" />
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 分页区域 -->
<Pagination
v-model:current-page="currentPage"
:total-pages="totalPages"
:total-items="filteredSongs.length"
:item-name="locale.paginationItemName"
/>
<!-- 移动端悬浮批量操作栏 -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 translate-y-full"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-full"
>
<div
v-if="selectedSongs.length > 0"
class="sm:hidden fixed bottom-[70px] left-4 right-4 z-40 bg-bg-secondary-95 border border-border-secondary rounded-2xl p-2 shadow-[0_8px_30px_var(--mobile-table-shadow)] backdrop-blur-xl flex items-center justify-between"
>
<div class="px-3 flex flex-col">
<span class="text-[10px] font-black text-text-tertiary uppercase tracking-widest">{{ locale.selected }}</span>
<span class="text-sm font-bold text-primary">{{ getLocaleMessage('selectedSongs', selectedSongs.length) }}</span>
</div>
<div class="flex items-center gap-2">
<button
class="flex flex-col items-center justify-center w-14 h-12 bg-bg-tertiary hover:bg-bg-quaternary text-success rounded-xl border border-border-tertiary-50 transition-all active:scale-95"
@click="openDownloadDialog"
>
<Download :size="16" class="mb-1" />
<span class="text-[9px] font-bold">{{ locale.actions.download }}</span>
</button>
<button
class="flex flex-col items-center justify-center w-14 h-12 bg-bg-tertiary hover:bg-bg-quaternary text-error rounded-xl border border-border-tertiary-50 transition-all active:scale-95"
@click="batchDelete"
>
<Trash2 :size="16" class="mb-1" />
<span class="text-[9px] font-bold">{{ locale.actions.delete }}</span>
</button>
</div>
</div>
</Transition>
<!-- Modals (模态框) -->
<!-- 确认删除对话框 -->
<ConfirmDialog
:show="showDeleteDialog"
:title="deleteDialogTitle"
:message="deleteDialogMessage"
:loading="loading"
type="danger"
:confirm-text="locale.dialog.confirmDelete"
:cancel-text="locale.dialog.cancel"
@confirm="confirmDelete"
@close="showDeleteDialog = false"
/>
<SubmissionRemarkDialog
:show="submissionRemarkDialog.show"
:song-title="submissionRemarkDialog.songTitle"
:content="submissionRemarkDialog.content"
:is-public="submissionRemarkDialog.isPublic"
:is-updating-public="submissionRemarkDialog.isUpdatingPublic"
:note-status="submissionRemarkDialog.status"
@close="submissionRemarkDialog.show = false"
@update:is-public="updateSubmissionNotePublic"
@approve="updateSubmissionNotePublicStatus('approved')"
@reject="updateSubmissionNotePublicStatus('rejected')"
/>
<!-- 驳回歌曲对话框 -->
<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-bg-primary-80 backdrop-blur-sm"
>
<div
class="w-full max-w-lg bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
@click.stop
>
<div class="px-8 py-6 border-b border-border-secondary-50 flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary">{{ locale.rejectDialog.title }}</h3>
<button
class="text-text-tertiary hover:text-text-secondary transition-colors"
@click="cancelReject"
>
<X :size="20" />
</button>
</div>
<div class="p-8 space-y-6">
<div class="p-5 bg-bg-primary border border-border-secondary rounded-lg flex items-center gap-4">
<div
class="w-10 h-10 rounded-lg bg-error-10 text-error flex items-center justify-center border border-error-10"
>
<Music :size="18" />
</div>
<div>
<h4 class="font-bold text-text-primary text-sm">{{ rejectSongInfo.title }}</h4>
<p class="text-xs text-text-tertiary">{{ locale.rejectDialog.requester(rejectSongInfo.requester) }}</p>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.rejectDialog.reason }}</label
>
<textarea
v-model="rejectReason"
:placeholder="locale.rejectDialog.reasonPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-4 text-sm text-text-primary focus:outline-none focus:border-error-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-border-secondary bg-bg-primary accent-red-600"
/>
<div>
<span
class="text-xs font-bold text-text-secondary group-hover:text-error transition-colors"
>{{ locale.rejectDialog.addToBlacklist }}</span
>
<p class="text-[10px] text-text-disabled font-medium">
{{ locale.rejectDialog.blacklistHint }}
</p>
</div>
</label>
</div>
<div class="px-8 py-6 bg-bg-secondary-50 border-t border-border-secondary-50 flex gap-3 justify-end">
<button
class="px-6 py-2.5 text-xs font-bold text-text-tertiary hover:text-text-secondary"
@click="cancelReject"
>
{{ locale.dialog.cancel }}
</button>
<button
:disabled="rejectLoading"
class="px-8 py-2.5 bg-error hover:bg-error text-text-primary text-xs font-black rounded-lg transition-all disabled:opacity-50"
@click="confirmReject"
>
{{ rejectLoading ? locale.rejectDialog.processing : locale.rejectDialog.confirm }}
</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-bg-primary-80 backdrop-blur-sm overflow-y-auto"
>
<div
class="w-full max-w-2xl bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden my-auto"
@click.stop
>
<div class="px-8 py-6 border-b border-border-secondary-50 flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary">
{{ showEditModal ? locale.editModal.editTitle : locale.editModal.addTitle }}
</h3>
<button
class="text-text-tertiary hover:text-text-secondary 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-text-disabled uppercase tracking-widest px-1"
>{{ locale.editModal.titleLabel }}</label
>
<input
v-if="showEditModal"
v-model="editForm.title"
type="text"
:placeholder="locale.editModal.titlePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 transition-all"
/>
<input
v-else
v-model="addForm.title"
type="text"
:placeholder="locale.editModal.titlePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 transition-all"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.editModal.artistLabel }}</label
>
<input
v-if="showEditModal"
v-model="editForm.artist"
type="text"
:placeholder="locale.editModal.artistPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 transition-all"
/>
<input
v-else
v-model="addForm.artist"
type="text"
:placeholder="locale.editModal.artistPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 transition-all"
/>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.editModal.requester }}</label
>
<div class="relative user-search-container">
<div class="relative">
<input
v-if="showEditModal"
v-model="editUserSearchQuery"
type="text"
:placeholder="locale.editModal.userSearchPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 transition-all"
@focus="showEditUserDropdown = true"
@input="searchEditUsers()"
/>
<input
v-else
v-model="userSearchQuery"
type="text"
:placeholder="locale.editModal.userSearchPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-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"
>
<AppSpinner :size="16" />
</div>
</div>
<!-- 用户下拉列表 -->
<div
v-if="
showEditModal
? showEditUserDropdown && filteredEditUsers.length > 0
: showUserDropdown && filteredUsers.length > 0
"
class="absolute z-10 w-full mt-2 bg-bg-secondary border border-border-secondary 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-bg-tertiary 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-text-primary">{{ user.name }}</span>
<span class="text-[10px] text-text-tertiary">@{{ user.username }}</span>
</div>
<Check
v-if="
showEditModal
? selectedEditUser?.id === user.id
: selectedUser?.id === user.id
"
:size="14"
class="text-primary"
/>
</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-primary"
>{{ locale.editModal.selectedUser((showEditModal ? selectedEditUser : selectedUser).name) }}
<template v-if="(showEditModal ? selectedEditUser : selectedUser).username">
(@{{ (showEditModal ? selectedEditUser : selectedUser).username }})
</template></span
>
<button
class="text-text-disabled hover:text-text-tertiary"
@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-text-disabled uppercase tracking-widest px-1"
>{{ locale.editModal.collaborators }}</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-bg-primary border border-border-secondary text-xs text-text-secondary"
>
{{ getCollaboratorDisplayName(collaborator) }}
<button
class="text-text-tertiary hover:text-error transition-colors"
@click="removeEditCollaborator(collaborator.id)"
>
<X :size="12" />
</button>
</span>
</div>
<div class="relative">
<input
v-model="editCollaboratorSearchQuery"
type="text"
:placeholder="locale.editModal.collaboratorSearchPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 transition-all"
@focus="showEditCollaboratorDropdown = true"
@input="searchEditCollaborators()"
/>
<div
v-if="editCollaboratorSearchLoading"
class="absolute right-4 top-1/2 -translate-y-1/2"
>
<AppSpinner :size="16" />
</div>
<div
v-if="showEditCollaboratorDropdown && filteredEditCollaborators.length > 0"
class="absolute z-10 w-full mt-2 bg-bg-secondary border border-border-secondary 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-bg-tertiary 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-text-primary">{{ user.name }}</span>
<span class="text-[10px] text-text-tertiary">@{{ user.username }}</span>
</div>
<Plus :size="14" class="text-primary opacity-70 group-hover:opacity-100" />
</div>
</div>
</div>
<p class="text-[10px] text-text-disabled font-medium px-1">
{{ locale.editModal.collaboratorHint }}
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.filters.semester }}</label
>
<CustomSelect
v-if="showEditModal"
v-model="editForm.semester"
:options="availableSemesters"
label-key="name"
value-key="name"
:placeholder="locale.filters.selectSemester"
/>
<CustomSelect
v-else
v-model="addForm.semester"
:options="availableSemesters"
label-key="name"
value-key="name"
:placeholder="locale.filters.selectSemester"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.editModal.preferredPlayTime }}</label
>
<CustomSelect
v-if="showEditModal"
v-model="editForm.preferredPlayTimeId"
:options="availablePlayTimes.filter((p) => p.id !== 'all')"
label-key="name"
value-key="id"
:placeholder="locale.editModal.selectPreferredPlayTime"
/>
<CustomSelect
v-else
v-model="addForm.preferredPlayTimeId"
:options="availablePlayTimes.filter((p) => p.id !== 'all')"
label-key="name"
value-key="id"
:placeholder="locale.editModal.selectPreferredPlayTime"
/>
</div>
</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-text-disabled uppercase tracking-widest"
>{{ locale.editModal.submissionNote }}</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-border-tertiary bg-bg-primary text-text-secondary hover:border-border-tertiary'
: 'border-warning-20 bg-warning-10 text-warning hover:bg-warning-20'
"
@click="
submissionNoteClearRequested
? cancelClearSubmissionNote()
: requestClearSubmissionNote()
"
>
{{ submissionNoteClearRequested ? locale.editModal.cancelClearNote : locale.editModal.clearNote }}
</button>
</div>
<textarea
v-model="editForm.submissionNote"
:disabled="submissionNoteClearRequested"
:placeholder="locale.editModal.submissionNotePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-4 text-sm text-text-primary focus:outline-none focus:border-primary-30 min-h-[120px] resize-none transition-all disabled:opacity-50 disabled:cursor-not-allowed"
/>
<label
class="flex items-center gap-3 px-1 transition-opacity"
:class="
submissionNoteClearRequested
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer group'
"
>
<input
v-model="editForm.submissionNotePublic"
:disabled="submissionNoteClearRequested"
type="checkbox"
class="w-4 h-4 rounded border-border-secondary bg-bg-primary"
/>
<div>
<span
class="text-xs font-bold transition-colors"
:class="submissionNoteClearRequested ? 'text-text-tertiary' : 'text-text-secondary group-hover:text-primary'"
>{{ locale.editModal.publicNote }}</span
>
<p class="text-[10px] text-text-disabled font-medium">
{{ locale.editModal.publicNoteHint }}
</p>
</div>
</label>
<div
v-if="submissionNoteClearRequested"
class="space-y-3 rounded-xl border border-warning-20 bg-warning-5 p-4"
>
<div class="text-xs font-bold text-warning">
{{ locale.editModal.clearNoteHint }}
</div>
<textarea
v-model="submissionNoteClearReason"
:placeholder="locale.editModal.clearNoteReasonPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-warning-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-border-secondary bg-bg-primary accent-amber-500"
/>
<div>
<span class="text-xs font-bold text-text-secondary group-hover:text-warning transition-colors"
>{{ locale.editModal.notifyAfterClear }}</span
>
<p class="text-[10px] text-text-disabled font-medium">
{{ locale.editModal.notifyAfterClearHint }}
</p>
</div>
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 pt-4 border-t border-border-secondary-50">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.editModal.musicPlatform }}</label
>
<CustomSelect
v-if="showEditModal"
v-model="editForm.musicPlatform"
:options="[
{ label: locale.platforms.netease, value: 'netease' },
{ label: locale.platforms.tencent, value: 'tencent' },
{ label: locale.platforms.bilibili, value: 'bilibili' }
]"
:placeholder="locale.editModal.selectPlatform"
/>
<CustomSelect
v-else
v-model="addForm.musicPlatform"
:options="[
{ label: locale.platforms.netease, value: 'netease' },
{ label: locale.platforms.tencent, value: 'tencent' },
{ label: locale.platforms.bilibili, value: 'bilibili' }
]"
:placeholder="locale.editModal.selectPlatform"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.editModal.musicId }}</label
>
<input
v-if="showEditModal"
v-model="editForm.musicId"
type="text"
:placeholder="locale.editModal.musicIdPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none transition-all"
/>
<input
v-else
v-model="addForm.musicId"
type="text"
:placeholder="locale.editModal.musicIdPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none transition-all"
/>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.editModal.duration }}</label
>
<div class="flex gap-2">
<input
v-if="showEditModal"
v-model="editForm.durationSeconds"
type="number"
min="0"
max="7200"
:placeholder="locale.editModal.durationPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none transition-all"
/>
<input
v-else
v-model="addForm.durationSeconds"
type="number"
min="0"
max="7200"
:placeholder="locale.editModal.durationPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none transition-all"
/>
<button
type="button"
:disabled="
refreshDurationLoading ||
(showEditModal
? !editForm.musicPlatform || !editForm.musicId
: !addForm.musicPlatform || !addForm.musicId)
"
class="px-4 py-3 bg-bg-tertiary-50 hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-lg transition-all border border-border-tertiary-30 disabled:opacity-50 disabled:cursor-not-allowed shrink-0 flex items-center gap-1.5"
:title="locale.editModal.refreshDuration"
@click="refreshDurationInModal"
>
<RotateCcw :size="14" :class="{ 'animate-spin': refreshDurationLoading }" />
{{ locale.editModal.refreshDuration }}
</button>
</div>
<p class="text-[10px] text-text-disabled font-medium px-1">
{{ locale.editModal.durationHint }}
</p>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.editModal.coverUrl }}</label
>
<input
v-if="showEditModal"
v-model="editForm.cover"
type="text"
placeholder="http://..."
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none transition-all"
/>
<input
v-else
v-model="addForm.cover"
type="text"
placeholder="http://..."
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none transition-all"
/>
<p
v-if="
(showEditModal ? editCoverValidation.valid : addCoverValidation.valid) &&
(showEditModal ? editForm.cover : addForm.cover)
"
class="text-[10px] text-success-80 font-bold px-1 flex items-center gap-1"
>
<Check :size="10" /> {{ locale.editModal.validUrl }}
</p>
<p
v-if="
!(showEditModal ? editCoverValidation.valid : addCoverValidation.valid) &&
(showEditModal ? editForm.cover : addForm.cover)
"
class="text-[10px] text-error-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-text-disabled uppercase tracking-widest px-1"
>{{ locale.editModal.playUrl }}</label
>
<input
v-if="showEditModal"
v-model="editForm.playUrl"
type="text"
placeholder="http://..."
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none transition-all"
/>
<input
v-else
v-model="addForm.playUrl"
type="text"
placeholder="http://..."
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-4 py-3 text-sm text-text-primary focus:outline-none transition-all"
/>
<p
v-if="
(showEditModal ? editPlayUrlValidation.valid : addPlayUrlValidation.valid) &&
(showEditModal ? editForm.playUrl : addForm.playUrl)
"
class="text-[10px] text-success-80 font-bold px-1 flex items-center gap-1"
>
<Check :size="10" /> {{ locale.editModal.validUrl }}
</p>
<p
v-if="
!(showEditModal ? editPlayUrlValidation.valid : addPlayUrlValidation.valid) &&
(showEditModal ? editForm.playUrl : addForm.playUrl)
"
class="text-[10px] text-error-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-bg-secondary-50 border-t border-border-secondary-50 flex gap-3 justify-end">
<button
class="px-6 py-2.5 text-xs font-bold text-text-tertiary hover:text-text-secondary"
@click="showEditModal ? cancelEditSong() : cancelAddSong()"
>
{{ locale.dialog.cancel }}
</button>
<button
:disabled="showEditModal ? editLoading : !canSubmitAddForm || addLoading"
class="px-8 py-2.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-lg transition-all disabled:opacity-50"
@click="showEditModal ? saveEditSong() : saveAddSong()"
>
{{
showEditModal
? editLoading
? locale.editModal.saving
: locale.editModal.saveChanges
: addLoading
? locale.editModal.adding
: locale.editModal.addSong
}}
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
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 AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import {
Search,
Plus,
RotateCcw,
Edit2,
Check,
X,
Trash2,
Music,
Heart,
Download,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ExternalLink,
MessageSquare
} 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 { useLocale } from '~/utils/locale'
import { isBilibiliSong } from '~/utils/bilibiliSource'
import { validateUrl, convertToHttps } from '~/utils/url'
import { formatDuration } from '~/utils/timeUtils'
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
dayjs.extend(customParseFormat)
// 响应式数据
const { showToast: showNotification } = useToast()
const { admin } = useLocale()
const locale = computed(() => {
const base = admin.value?.songManagement || {}
const emptyText = () => ''
return useSafeLocale({
...base,
stats: { ...(base.stats || {}) },
actions: { ...(base.actions || {}) },
filters: { ...(base.filters || {}) },
table: { ...(base.table || {}) },
empty: { ...(base.empty || {}) },
dialog: { ...(base.dialog || {}) },
rejectDialog: {
requester: emptyText,
...(base.rejectDialog || {})
},
editModal: {
selectedUser: emptyText,
...(base.editModal || {})
},
timeAgo: {
...(base.timeAgo || {}),
minutes: (value) => formatLocaleValue(base.timeAgo?.minutes, value) || '',
hours: (value) => formatLocaleValue(base.timeAgo?.hours, value) || '',
days: (value) => formatLocaleValue(base.timeAgo?.days, value) || ''
}
})
})
const { msg: getLocaleMessage, nested: getNestedMessage } = useLocaleText(locale)
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 selectedSemesterName = computed(() => {
if (!selectedSemester.value || selectedSemester.value === 'all') return ''
return availableSemesters.value.find((semester) => String(semester.id) === String(selectedSemester.value))?.name || ''
})
// 时段相关
const selectedPlayTime = ref('all')
const availablePlayTimes = ref([])
// 选项配置
const statusOptions = computed(() => [
{ label: getNestedMessage('options', 'allStatus'), value: 'all' },
{ label: getNestedMessage('options', 'pending'), value: 'pending' },
{ label: getNestedMessage('options', 'scheduled'), value: 'scheduled' },
{ label: getNestedMessage('options', 'played'), value: 'played' },
{ label: getNestedMessage('options', 'hasNote'), value: 'has-note' }
])
const sortOptions = computed(() => [
{ label: getNestedMessage('options', 'newest'), value: 'time-desc' },
{ label: getNestedMessage('options', 'oldest'), value: 'time-asc' },
{ label: getNestedMessage('options', 'hotDesc'), value: 'votes-desc' },
{ label: getNestedMessage('options', 'hotAsc'), value: 'votes-asc' },
{ label: getNestedMessage('options', 'titleAsc'), value: 'title-asc' },
{ label: getNestedMessage('options', 'titleDesc'), 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,
songId: null,
replayRequestId: null,
title: '',
artist: '',
songTitle: '',
content: '',
isPublic: false,
isUpdatingPublic: false,
status: null
})
// 驳回歌曲相关
const showRejectDialog = ref(false)
const rejectLoading = ref(false)
const rejectReason = ref('')
const addToBlacklist = ref(false)
const rejectSongInfo = ref({
id: null,
title: '',
artist: '',
requester: ''
})
// 编辑歌曲相关
const showEditModal = ref(false)
const editLoading = ref(false)
const refreshDurationLoading = ref(false)
const editForm = ref({
id: null,
title: '',
artist: '',
requester: '',
semester: '',
preferredPlayTimeId: 'none',
submissionNote: '',
submissionNotePublic: false,
musicPlatform: '',
musicId: '',
cover: '',
playUrl: '',
durationSeconds: ''
})
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: '',
preferredPlayTimeId: 'none',
musicPlatform: '',
musicId: '',
cover: '',
playUrl: '',
durationSeconds: ''
})
// 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
let formatPlayTimeDisplay = (pt) => pt?.name || ''
// 计算属性
const baseFilteredSongs = 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 === selectedSemesterName.value)
}
// 时段过滤
if (selectedPlayTime.value && selectedPlayTime.value !== 'all') {
filtered = filtered.filter((song) => {
if (selectedPlayTime.value === 'none') {
return !song.preferredPlayTimeId
}
return song.preferredPlayTimeId === selectedPlayTime.value
})
}
return filtered
})
const filteredSongs = computed(() => {
let filtered = [...baseFilteredSongs.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 baseFilteredSongs.value.filter((song) => song.played).length
})
const pendingCount = computed(() => {
return baseFilteredSongs.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 = getSyncedDate()
const diff = now - date
if (diff < 60000) return getNestedMessage('timeAgo', 'justNow')
if (diff < 3600000) return getNestedMessage('timeAgo', 'minutes', Math.floor(diff / 60000))
if (diff < 86400000) return getNestedMessage('timeAgo', 'hours', Math.floor(diff / 3600000))
return getNestedMessage('timeAgo', 'days', Math.floor(diff / 86400000))
}
const getPlayTimeName = (playTimeId) => {
if (!playTimeId || !availablePlayTimes.value) return ''
const playTime = availablePlayTimes.value.find((pt) => pt.id === playTimeId)
if (!playTime) return ''
return formatPlayTimeDisplay(playTime)
}
const getStatusText = (song) => {
if (song.played) return getNestedMessage('options', 'played')
if (song.scheduled) {
// 如果有排期日期,在待播放后显示排期日期(月/日)
if (song.scheduleDate) {
// API返回的日期格式为 "YYYY/M/D H:mm:ss"(如 "2024/1/15 14:30:00")
const date = dayjs(song.scheduleDate, 'YYYY/M/D H:mm:ss')
if (date.isValid()) {
return getLocaleMessage('scheduledWithDate', date.format('M/D'))
}
}
return getNestedMessage('options', 'scheduled')
}
return getNestedMessage('options', 'pending')
}
const updateSubmissionNotePublicStatus = async (status) => {
const dialogData = submissionRemarkDialog.value
if (!dialogData.songId || dialogData.isUpdatingPublic) return
// 兜底文案:避免异步回调里 getNestedMessage 因代码分割/作用域问题抛 ReferenceError
// 完全不调用 getNestedMessage 也能正常显示通知(避免任何作用域/死代码消除风险)
const fallbackText = {
success: status === 'approved' ? '已通过审核' : '已拒绝审核',
failure: '更新审核状态失败'
}
const safeMessage = (section, key, fallback) => {
try {
const text = getNestedMessage(section, key)
return text || fallback
} catch {
return fallback
}
}
dialogData.isUpdatingPublic = true
try {
const updatePayload = {
title: dialogData.title,
artist: dialogData.artist,
submissionNotePublicStatus: status
}
if (dialogData.replayRequestId) {
updatePayload.replayRequestId = dialogData.replayRequestId
}
await adminService.updateSong(dialogData.songId, updatePayload)
const songIndex = songs.value.findIndex((s) => s.id === dialogData.songId)
if (songIndex !== -1) {
songs.value[songIndex].submissionNotePublicStatus = status
if (status === 'approved') {
songs.value[songIndex].submissionNotePublic = true
}
}
dialogData.status = status
if (status === 'approved') {
dialogData.isPublic = true
}
if (window.$showNotification) {
try {
const successKey = status === 'rejected' ? 'remarkRejected' : 'remarkApproved'
window.$showNotification(safeMessage('messages', successKey, fallbackText.success), 'success')
} catch (notifyErr) {
// 静默失败,不影响主流程
}
}
} catch (error) {
console.error('更新备注审核状态失败:', error)
if (window.$showNotification) {
try {
window.$showNotification(safeMessage('errors', 'remarkUpdateFailed', fallbackText.failure), 'error')
} catch (notifyErr) {
// 静默失败
}
}
} finally {
dialogData.isUpdatingPublic = false
}
}
const getCollaboratorDisplayName = (user) => {
return user?.displayName || user?.name || user?.username || getLocaleMessage('unknownUser')
}
const openSubmissionRemark = (song) => {
if (!song?.submissionNote) return
submissionRemarkDialog.value = {
show: true,
songId: song.id,
// 歌曲列表审核针对歌曲自身备注;重播备注审核从排期/重播列表发起(避免被 replayRequestId 劫持)
replayRequestId: null,
title: song.title,
artist: song.artist,
songTitle: `${song.title} - ${song.artist}`,
content: song.submissionNote,
isPublic: song.submissionNotePublic === true,
status: song.submissionNotePublicStatus || null
}
}
const updateSubmissionNotePublic = async (isPublic) => {
const dialogData = submissionRemarkDialog.value
if (!dialogData.songId || dialogData.isUpdatingPublic) return
dialogData.isUpdatingPublic = true
dialogData.isPublic = isPublic
try {
const updatePayload = {
title: dialogData.title,
artist: dialogData.artist,
submissionNotePublic: isPublic
}
// 如果是重播申请,传入 replayRequestId 以更新重播申请的备注可见性
if (dialogData.replayRequestId) {
updatePayload.replayRequestId = dialogData.replayRequestId
}
await adminService.updateSong(dialogData.songId, updatePayload)
const songIndex = songs.value.findIndex((s) => s.id === dialogData.songId)
if (songIndex !== -1) {
songs.value[songIndex].submissionNotePublic = isPublic
songs.value[songIndex].submissionNotePublicStatus = isPublic ? 'approved' : null
}
dialogData.status = isPublic ? 'approved' : null
if (window.$showNotification) {
try {
window.$showNotification(safeMessage('messages', 'remarkVisibilityUpdated', '备注留言可见性已更新'), 'success')
} catch (notifyErr) {
// 静默失败,不影响主流程
}
}
} catch (error) {
console.error('更新备注可见性失败:', error)
if (window.$showNotification) {
try {
window.$showNotification(safeMessage('errors', 'remarkVisibilityUpdateFailed', '备注留言可见性更新失败'), 'error')
} catch (notifyErr) {
// 静默失败,不影响主流程
}
}
dialogData.isPublic = !isPublic
} finally {
dialogData.isUpdatingPublic = false
}
}
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 (forceRefresh = false) => {
loading.value = true
try {
await songsService.fetchSongs(false, undefined, forceRefresh)
songs.value = songsService.songs.value || []
selectedSongs.value = []
} catch (error) {
console.error('刷新歌曲失败:', error)
} finally {
loading.value = false
}
}
const markAsPlayed = async (songId) => {
try {
await songsService.markPlayed(songId)
await refreshSongs()
} catch (error) {
console.error('标记已播放失败:', error)
showNotification(getNestedMessage('errors', 'markFailed', error?.data?.message || error?.message || error?.statusMessage || getLocaleMessage('unknownError')), 'error')
}
}
const markAsUnplayed = async (songId) => {
try {
await songsService.unmarkPlayed(songId)
await refreshSongs()
} catch (error) {
console.error('标记未播放失败:', error)
showNotification(getNestedMessage('errors', 'markFailed', error?.data?.message || error?.message || error?.statusMessage || getLocaleMessage('unknownError')), 'error')
}
}
const deleteSong = async (songId) => {
const song = songs.value.find((s) => s.id === songId)
if (!song) return
deleteDialogTitle.value = getNestedMessage('dialog', 'deleteSongTitle')
deleteDialogMessage.value = getNestedMessage('dialog', 'deleteSongMessage', 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(getNestedMessage('messages', 'deleteSuccess'), 'success')
} catch (error) {
console.error('删除歌曲失败:', error)
showNotification(getNestedMessage('errors', 'deleteFailed', error?.data?.message || error?.message || error?.statusMessage || getLocaleMessage('unknownError')), 'error')
}
}
showDeleteDialog.value = true
}
const batchDelete = async () => {
if (selectedSongs.value.length === 0) return
deleteDialogTitle.value = getNestedMessage('dialog', 'batchDeleteTitle')
deleteDialogMessage.value = getNestedMessage('dialog', 'batchDeleteMessage', 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(getNestedMessage('messages', 'batchDeleteSuccess'), 'success')
} catch (error) {
console.error('批量删除失败:', error)
showNotification(getNestedMessage('errors', 'batchDeleteFailed', error?.data?.message || error?.message || error?.statusMessage || getLocaleMessage('unknownError')), '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 || getLocaleMessage('unknown'),
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
rejectSongInfo.value = {
id: song.id,
title: song.title || '',
artist: song.artist || '',
requester: song.requester || song.requester_name || getLocaleMessage('unknown')
}
rejectReason.value = ''
addToBlacklist.value = false
showRejectDialog.value = true
}
// 确认驳回
const confirmReject = async () => {
if (!rejectReason.value.trim()) {
showNotification(getNestedMessage('errors', 'rejectReasonRequired'), 'error')
return
}
rejectLoading.value = true
try {
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)
}
showRejectDialog.value = false
showNotification(getNestedMessage('messages', 'rejectSuccess'), 'success')
} catch (error) {
console.error('驳回歌曲失败:', error)
showNotification(getNestedMessage('errors', 'rejectFailed', getErrorMessage(error)), 'error')
} finally {
rejectLoading.value = false
}
}
// 取消驳回
const cancelReject = () => {
showRejectDialog.value = false
rejectReason.value = ''
addToBlacklist.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 || '',
preferredPlayTimeId: song.preferredPlayTimeId || 'none',
submissionNote: song.submissionNote || '',
submissionNotePublic: song.submissionNotePublic === true,
musicPlatform: song.musicPlatform || '',
musicId: song.musicId || '',
cover: song.cover || '',
playUrl: song.playUrl || '',
durationSeconds: song.durationSeconds || ''
}
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 || locale.value.unknown,
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(getNestedMessage('errors', 'songInfoRequired'), 'error')
return
}
if (editForm.value.cover && !editCoverValidation.value.valid) {
showNotification(getNestedMessage('errors', 'coverUrlInvalidOrPending'), 'error')
return
}
if (editForm.value.playUrl && !editPlayUrlValidation.value.valid) {
showNotification(getNestedMessage('errors', 'playUrlInvalidOrPending'), 'error')
return
}
if (editCoverValidation.value.validating || editPlayUrlValidation.value.validating) {
showNotification(getNestedMessage('messages', 'validatingUrl'), 'warning')
return
}
// 校验时长范围(30秒~1小时)
const editDuration = editForm.value.durationSeconds
if (editDuration !== '' && editDuration != null) {
const d = Number(editDuration)
if (!Number.isFinite(d) || d < 30 || d > 3600) {
showNotification(getNestedMessage('errors', 'durationInvalidRange'), 'error')
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,
preferredPlayTimeId:
editForm.value.preferredPlayTimeId === 'none'
? null
: editForm.value.preferredPlayTimeId || null,
submissionNote: submissionNoteClearRequested.value ? null : editForm.value.submissionNote,
submissionNotePublic: submissionNoteClearRequested.value
? false
: editForm.value.submissionNotePublic,
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,
durationSeconds:
editForm.value.durationSeconds === '' || editForm.value.durationSeconds == null
? null
: Number(editForm.value.durationSeconds)
})
await refreshSongs()
showEditModal.value = false
showNotification(getNestedMessage('messages', 'updateSuccess'), 'success')
} catch (error) {
console.error('更新歌曲失败:', error)
let errorMessage = getNestedMessage('errors', 'updateFailed')
errorMessage = getErrorMessage(error) || errorMessage
showNotification(errorMessage, 'error')
} finally {
editLoading.value = false
}
}
// 弹窗内刷新歌曲时长(编辑模式按 songId,新增模式按平台+音乐ID)
const refreshDurationInModal = async () => {
const isEdit = showEditModal.value
const form = isEdit ? editForm.value : addForm.value
if (!form.musicPlatform || !form.musicId) {
showNotification(getNestedMessage('errors', 'durationPlatformRequired'), 'warning')
return
}
refreshDurationLoading.value = true
try {
const body = isEdit
? { songId: editForm.value.id }
: { platform: addForm.value.musicPlatform, musicId: addForm.value.musicId }
const result = await $fetch('/api/admin/songs/duration', {
method: 'POST',
body
})
if (result.success && result.durationSeconds) {
if (isEdit) {
editForm.value.durationSeconds = result.durationSeconds
} else {
addForm.value.durationSeconds = result.durationSeconds
}
showNotification(getNestedMessage('messages', 'durationRefreshed'), 'success')
} else {
showNotification(getNestedMessage('errors', 'durationRefreshFailed', result?.message), 'error')
}
} catch (error) {
console.error('刷新歌曲时长失败:', error)
showNotification(getNestedMessage('errors', 'durationRefreshFailed', getErrorMessage(error)), 'error')
} finally {
refreshDurationLoading.value = false
}
}
const cancelEditSong = () => {
showEditModal.value = false
editForm.value = {
id: null,
title: '',
artist: '',
requester: '',
semester: '',
preferredPlayTimeId: 'none',
submissionNote: '',
submissionNotePublic: false,
musicPlatform: '',
musicId: '',
cover: '',
playUrl: '',
durationSeconds: ''
}
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: selectedSemesterName.value,
preferredPlayTimeId: selectedPlayTime.value !== 'all' && selectedPlayTime.value !== 'none' ? selectedPlayTime.value : 'none',
musicPlatform: '',
musicId: '',
cover: '',
playUrl: '',
durationSeconds: ''
}
showAddSongModal.value = true
}
const saveAddSong = async () => {
if (!addForm.value.title || !addForm.value.artist) {
showNotification(getNestedMessage('errors', 'songInfoRequired'), 'error')
return
}
if (!selectedUser.value || !addForm.value.requester) {
showNotification(getNestedMessage('errors', 'requesterRequired'), 'error')
return
}
if (!addForm.value.semester) {
showNotification(getNestedMessage('errors', 'semesterRequired'), 'error')
return
}
if (addForm.value.cover) {
if (!addCoverValidation.value.valid) {
showNotification(getNestedMessage('errors', 'coverUrlInvalid'), 'error')
return
}
if (addCoverValidation.value.validating) {
showNotification(getNestedMessage('messages', 'validatingCoverUrl'), 'warning')
return
}
}
if (addForm.value.playUrl) {
if (!addPlayUrlValidation.value.valid) {
showNotification(getNestedMessage('errors', 'playUrlInvalid'), 'error')
return
}
if (addPlayUrlValidation.value.validating) {
showNotification(getNestedMessage('messages', 'validatingPlayUrl'), 'warning')
return
}
}
// 校验时长范围(30秒~1小时)
const addDuration = addForm.value.durationSeconds
if (addDuration !== '' && addDuration != null) {
const d = Number(addDuration)
if (!Number.isFinite(d) || d < 30 || d > 3600) {
showNotification(getNestedMessage('errors', 'durationInvalidRange'), 'error')
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,
preferredPlayTimeId:
addForm.value.preferredPlayTimeId === 'none'
? null
: addForm.value.preferredPlayTimeId || null,
musicPlatform: addForm.value.musicPlatform || null,
musicId: addForm.value.musicId || null,
cover: addForm.value.cover || null,
playUrl: addForm.value.playUrl || null,
durationSeconds:
addForm.value.durationSeconds === '' || addForm.value.durationSeconds == null
? null
: Number(addForm.value.durationSeconds)
})
await refreshSongs()
showAddSongModal.value = false
addForm.value = {
title: '',
artist: '',
requester: '',
semester: '',
preferredPlayTimeId: 'none',
musicPlatform: '',
musicId: '',
cover: '',
playUrl: '',
durationSeconds: ''
}
clearSelectedUser()
showNotification(getNestedMessage('messages', 'addSuccess'), 'success')
} catch (error) {
console.error('添加歌曲失败:', error)
let errorMessage = getNestedMessage('errors', 'addFailed')
errorMessage = getErrorMessage(error) || errorMessage
showNotification(errorMessage, 'error')
} finally {
addLoading.value = false
}
}
const cancelAddSong = () => {
showAddSongModal.value = false
addForm.value = {
title: '',
artist: '',
requester: '',
semester: '',
preferredPlayTimeId: 'none',
musicPlatform: '',
musicId: '',
cover: '',
playUrl: '',
durationSeconds: ''
}
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, selectedPlayTime], () => {
currentPage.value = 1
})
// 生命周期
const { semesters, fetchSemesters, semesterUpdateEvent } = useSemesters()
watch(semesterUpdateEvent, async () => {
await fetchSemesters()
availableSemesters.value = [{ id: 'all', name: locale.value.allSemesters }, ...(semesters.value || [])]
})
onMounted(async () => {
songsService = useSongs()
adminService = useAdmin()
auth = useAuth()
const { fetchCurrentSemester, currentSemester } = useSemesters()
await fetchSemesters()
await fetchCurrentSemester()
availableSemesters.value = [{ id: 'all', name: locale.value.allSemesters }, ...(semesters.value || [])]
if (currentSemester.value) {
selectedSemester.value = currentSemester.value.id
}
const { fetchPlayTimes, playTimes, formatPlayTimeDisplay: formatter } = songsService
formatPlayTimeDisplay = formatter
await fetchPlayTimes()
availablePlayTimes.value = [...(playTimes.value || [])]
availablePlayTimes.value.unshift({ id: 'none', name: locale.value.unspecifiedPlayTime })
availablePlayTimes.value.unshift({ id: 'all', name: locale.value.allPlayTimes })
document.addEventListener('click', handleClickOutside)
await refreshSongs()
})
// 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(--panel-bg-alt);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--panel-bg-hover);
}
</style>