5720 lines
212 KiB
Vue
5720 lines
212 KiB
Vue
<template>
|
||
<div class="space-y-6 pb-24 md:pb-8">
|
||
<!-- 日期选择器 -->
|
||
<div class="relative bg-bg-secondary-50 border border-border-secondary-50 rounded-2xl p-1 overflow-hidden">
|
||
<div class="flex items-center" @touchstart.stop>
|
||
<button
|
||
:disabled="isFirstDateVisible"
|
||
class="p-2 text-text-tertiary hover:text-text-secondary disabled:opacity-30 transition-colors"
|
||
@click="scrollDates('left')"
|
||
>
|
||
<svg
|
||
class="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<polyline points="15,18 9,12 15,6" />
|
||
</svg>
|
||
</button>
|
||
|
||
<div
|
||
ref="dateSelector"
|
||
class="flex-1 flex overflow-x-auto scrollbar-hide gap-2 px-2 py-1 overscroll-x-contain"
|
||
style="overscroll-behavior-x: contain; touch-action: pan-x"
|
||
>
|
||
<button
|
||
v-for="date in availableDates"
|
||
:key="date.value"
|
||
:data-date="date.value"
|
||
:class="[
|
||
'flex flex-col items-center justify-center min-w-[64px] h-16 rounded-lg transition-all duration-200 border',
|
||
selectedDate === date.value
|
||
? 'bg-primary-hover border-primary text-text-primary shadow-lg shadow-[var(--primary-glow)]'
|
||
: 'bg-bg-secondary border-border-secondary text-text-tertiary hover:bg-bg-tertiary hover:text-text-secondary hover:border-border-tertiary'
|
||
]"
|
||
@click="handleDateSelect(date.value)"
|
||
>
|
||
<span class="text-[10px] font-bold uppercase tracking-wider opacity-80">{{
|
||
date.weekday
|
||
}}</span>
|
||
<span class="text-lg font-black leading-none my-0.5">{{ date.day }}</span>
|
||
<span class="text-[9px] font-bold opacity-60">{{ callLocale('monthLabel', `${date.month}月`, date.month) }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
:disabled="isLastDateVisible"
|
||
class="p-2 text-text-tertiary hover:text-text-secondary disabled:opacity-30 transition-colors"
|
||
@click="scrollDates('right')"
|
||
>
|
||
<svg
|
||
class="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<polyline points="9,18 15,12 9,6" />
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- 操作按钮组 -->
|
||
<div class="flex items-center border-l border-border-secondary ml-1 pl-1">
|
||
<!-- 定位到今天 -->
|
||
<button
|
||
class="p-2 text-text-tertiary hover:text-success transition-colors"
|
||
:title="locale.jumpToday"
|
||
@click="scrollToToday"
|
||
>
|
||
<CircleDot class="w-5 h-5" />
|
||
</button>
|
||
|
||
<!-- 手动日期选择按钮 -->
|
||
<button
|
||
class="p-2 text-text-tertiary hover:text-primary transition-colors"
|
||
:title="locale.selectSpecificDate"
|
||
@click="openManualDatePicker"
|
||
>
|
||
<CalendarIcon class="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 手动日期选择弹窗 -->
|
||
<div
|
||
v-if="showManualDatePicker"
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-bg-primary-60 backdrop-blur-sm"
|
||
>
|
||
<div
|
||
class="bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl w-full max-w-sm overflow-hidden"
|
||
@click.stop
|
||
>
|
||
<div class="flex items-center justify-between p-4 border-b border-border-secondary">
|
||
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest">{{ locale.selectDate }}</h3>
|
||
<button
|
||
class="text-text-tertiary hover:text-text-secondary transition-colors"
|
||
@click="showManualDatePicker = false"
|
||
>
|
||
<CloseIcon class="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
<div class="p-6 space-y-6">
|
||
<input
|
||
v-model="manualSelectedDate"
|
||
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-text-primary focus:outline-none focus:border-primary transition-colors"
|
||
type="date"
|
||
/>
|
||
<div class="flex gap-3">
|
||
<button
|
||
class="flex-1 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
|
||
@click="showManualDatePicker = false"
|
||
>
|
||
{{ locale.cancel }}
|
||
</button>
|
||
<button
|
||
class="flex-1 py-3 bg-primary-hover hover:bg-primary text-text-primary text-xs font-bold rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-colors uppercase tracking-wider"
|
||
@click="confirmManualDate"
|
||
>
|
||
{{ locale.confirm }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 播出时段选择器 (如果启用) -->
|
||
<div
|
||
v-if="playTimeEnabled"
|
||
class="flex items-center gap-3 bg-bg-secondary-30 border border-border-secondary rounded-lg p-3"
|
||
>
|
||
<CustomSelect
|
||
:model-value="selectedPlayTime"
|
||
:label="locale.playTime"
|
||
:options="playTimeOptions"
|
||
class-name="w-full"
|
||
@update:model-value="handlePlayTimeSelect"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 加载状态 -->
|
||
<div v-if="loading" class="flex flex-col items-center justify-center py-20 min-h-[60vh]">
|
||
<LoadingState :title="locale.loadingTitle" :message="locale.loadingMessage" />
|
||
</div>
|
||
|
||
<div v-else>
|
||
<div
|
||
class="lg:hidden sticky -top-4 -mx-4 -mt-4 z-20 flex p-1 bg-bg-primary-90 backdrop-blur-md border-b border-border-secondary shadow-xl mb-4 pt-4"
|
||
>
|
||
<button
|
||
:class="[
|
||
'flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-black uppercase tracking-widest transition-all',
|
||
mobileTab === 'pending' ? 'bg-primary-hover text-text-primary shadow-lg' : 'text-text-tertiary'
|
||
]"
|
||
@click="mobileTab = 'pending'"
|
||
>
|
||
<ListMusic class="w-4 h-4" />
|
||
<span class="flex items-center gap-1.5"
|
||
>{{ locale.pendingSongs }}
|
||
<span class="px-1.5 py-0.5 bg-bg-tertiary text-[10px] rounded text-text-tertiary">{{
|
||
filteredUnscheduledSongs.length
|
||
}}</span></span
|
||
>
|
||
</button>
|
||
<button
|
||
:class="[
|
||
'flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-black uppercase tracking-widest transition-all',
|
||
mobileTab === 'scheduled' ? 'bg-primary-hover text-text-primary shadow-lg' : 'text-text-tertiary'
|
||
]"
|
||
@click="mobileTab = 'scheduled'"
|
||
>
|
||
<PlaySquare class="w-4 h-4" />
|
||
<span class="flex items-center gap-1.5"
|
||
>{{ locale.playlist }}
|
||
<span class="px-1.5 py-0.5 bg-bg-tertiary text-[10px] rounded text-text-tertiary">{{
|
||
localScheduledSongs.length
|
||
}}</span></span
|
||
>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-0">
|
||
<!-- 左侧:待排歌曲(待排库) -->
|
||
<div
|
||
v-show="mobileTab === 'pending' || isDesktop"
|
||
:class="[
|
||
'lg:col-span-4 flex flex-col space-y-2',
|
||
mobileTab === 'scheduled' ? 'hidden lg:flex' : 'flex'
|
||
]"
|
||
@dragover.prevent="handleDraggableDragOver"
|
||
@dragleave="handleDraggableDragLeave"
|
||
@drop.stop.prevent="handleReturnToDraggable"
|
||
>
|
||
<div class="flex flex-wrap items-center justify-between gap-y-2 px-1">
|
||
<h3
|
||
class="hidden lg:block text-lg font-black tracking-tight text-text-primary uppercase whitespace-nowrap shrink-0"
|
||
>
|
||
{{ activeTab === 'pool' ? locale.poolList : locale.pendingSongs }}
|
||
</h3>
|
||
<div class="flex items-center gap-2 w-full lg:w-auto">
|
||
<div
|
||
class="flex flex-1 lg:flex-none flex-wrap gap-1 p-1 bg-bg-secondary-50 rounded-xl border border-border-secondary"
|
||
>
|
||
<button
|
||
v-for="tab in scheduleTabs"
|
||
:key="tab.id"
|
||
:class="[
|
||
'flex-1 lg:flex-none px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||
activeTab === tab.id
|
||
? 'bg-bg-tertiary text-primary shadow-md border border-primary-20'
|
||
: 'text-text-disabled hover:text-text-tertiary'
|
||
]"
|
||
@click="activeTab = tab.id"
|
||
>
|
||
{{ tab.label }}
|
||
</button>
|
||
</div>
|
||
<button
|
||
class="hidden lg:flex p-1.5 bg-bg-secondary-50 rounded-lg border border-border-secondary text-text-tertiary hover:text-info hover:border-info-30 transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
|
||
v-if="activeTab === 'normal' || activeTab === 'all' || activeTab === 'replay'"
|
||
:disabled="filteredUnscheduledSongs.filter((song) => !poolSongIds.has(song.id)).length === 0"
|
||
:title="locale.addCurrentPage"
|
||
@click="moveAllToPool"
|
||
>
|
||
<FolderPlus class="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
class="hidden lg:flex items-center justify-center gap-1 p-1.5 bg-bg-secondary-50 rounded-lg border border-border-secondary text-text-tertiary hover:text-primary hover:border-primary-30 transition-all group relative overflow-hidden disabled:opacity-70 disabled:cursor-not-allowed"
|
||
:disabled="refreshingAllDurations.running"
|
||
:title="refreshingAllDurations.running ? `${locale.refreshPageDurations} (${refreshingAllDurations.progress})` : locale.refreshPageDurations"
|
||
@click="refreshAllDurations"
|
||
>
|
||
<RefreshCcw
|
||
class="w-3.5 h-3.5"
|
||
:class="{ 'animate-spin': refreshingAllDurations.running }"
|
||
/>
|
||
<span
|
||
v-if="refreshingAllDurations.running"
|
||
class="text-[9px] font-bold tabular-nums whitespace-nowrap"
|
||
>{{ refreshingAllDurations.done }}/{{ refreshingAllDurations.total }}</span>
|
||
<span
|
||
v-if="refreshingAllDurations.running"
|
||
class="absolute inset-x-0 bottom-0 h-0.5 bg-bg-tertiary"
|
||
>
|
||
<span
|
||
class="block h-full bg-primary transition-[width] duration-300"
|
||
:style="{ width: `${refreshingAllDurations.total > 0 ? Math.round((refreshingAllDurations.done / refreshingAllDurations.total) * 100) : 0}%` }"
|
||
/>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 筛选区 - 移动端折叠 -->
|
||
<div class="bg-bg-secondary-40 border border-border-secondary rounded-2xl shadow-xl">
|
||
<div
|
||
class="p-4 flex items-center justify-between lg:hidden border-b border-border-secondary-50 rounded-t-2xl"
|
||
@click="mobileFiltersOpen = !mobileFiltersOpen"
|
||
>
|
||
<div class="flex items-center gap-2 text-text-tertiary">
|
||
<Filter class="w-3.5 h-3.5" />
|
||
<span class="text-[11px] font-black uppercase tracking-widest">{{ locale.searchAndFilter }}</span>
|
||
</div>
|
||
<ChevronRight
|
||
:class="[
|
||
'w-3.5 h-3.5 text-text-secondary transition-transform duration-300',
|
||
mobileFiltersOpen ? 'rotate-90' : ''
|
||
]"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
v-show="mobileFiltersOpen || isDesktop"
|
||
class="p-3 space-y-2 transition-all duration-300 ease-in-out rounded-b-2xl"
|
||
>
|
||
<div class="relative">
|
||
<Search
|
||
class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary w-3.5 h-3.5"
|
||
/>
|
||
<input
|
||
v-model="searchQuery"
|
||
type="text"
|
||
:placeholder="locale.searchPlaceholder"
|
||
class="w-full pl-9 pr-4 py-2 bg-bg-primary border border-border-secondary rounded-xl text-xs focus:outline-none focus:border-primary-30 transition-all text-text-primary"
|
||
/>
|
||
<button
|
||
v-if="searchQuery"
|
||
class="absolute right-3 top-1/2 -translate-y-1/2 text-text-disabled hover:text-text-tertiary"
|
||
@click="searchQuery = ''"
|
||
>
|
||
<CloseIcon class="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
<div class="grid grid-cols-1 gap-2">
|
||
<CustomSelect
|
||
:model-value="selectedSemester"
|
||
:label="locale.currentSemester"
|
||
:options="availableSemesters"
|
||
label-key="name"
|
||
value-key="id"
|
||
@update:model-value="handleSemesterSelect"
|
||
/>
|
||
<CustomSelect
|
||
v-if="playTimeEnabled"
|
||
v-model="selectedFilterPlayTime"
|
||
:label="locale.preferredTime"
|
||
:options="filterPlayTimeOptions"
|
||
label-key="label"
|
||
value-key="value"
|
||
/>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<CustomSelect
|
||
v-model="selectedGrade"
|
||
:label="locale.grade"
|
||
:options="availableGrades"
|
||
label-key="label"
|
||
value-key="value"
|
||
/>
|
||
<CustomSelect v-model="songSortOption" :label="locale.sort" :options="sortOptions" />
|
||
</div>
|
||
<button
|
||
class="flex items-center justify-center gap-2 w-full px-4 py-2 bg-bg-primary border border-border-secondary hover:border-primary-30 hover:text-primary rounded-xl text-xs focus:outline-none transition-all text-text-secondary"
|
||
:class="{
|
||
'border-primary-50 text-primary bg-primary-10': isPlaylistFilterActive
|
||
}"
|
||
@click="showPlaylistFilterModal = true"
|
||
>
|
||
<ListMusic class="w-3.5 h-3.5" />
|
||
<span>{{ isPlaylistFilterActive ? locale.playlistFilterApplied : locale.playlistFilter }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
:class="[
|
||
'draggable-songs flex-1 border-2 border-dashed rounded-[2rem] p-2 md:p-3 min-h-[400px] transition-colors duration-200',
|
||
isDraggableOver
|
||
? 'border-primary bg-primary-5'
|
||
: 'border-border-secondary-80 bg-bg-secondary-20'
|
||
]"
|
||
>
|
||
<div class="space-y-2">
|
||
<div
|
||
v-for="song in filteredUnscheduledSongs"
|
||
:key="song.id"
|
||
:class="[
|
||
'draggable-song relative group rounded-xl p-3 transition-all select-none',
|
||
song.cardCodeId
|
||
? 'bg-warning-5 border border-warning-30'
|
||
: 'bg-bg-secondary border border-border-secondary-50 hover:border-border-tertiary'
|
||
]"
|
||
:draggable="true"
|
||
@dragend="dragEnd"
|
||
@dragstart="dragStart($event, song)"
|
||
@touchend="handleTouchEnd"
|
||
@touchmove="handleTouchMove"
|
||
@touchstart="handleTouchStart($event, song, 'song')"
|
||
>
|
||
<!-- 歌曲卡片内容 -->
|
||
<div class="flex items-center gap-3">
|
||
<!-- 封面图片 -->
|
||
<div
|
||
class="relative w-12 h-12 rounded-lg overflow-hidden bg-bg-tertiary flex-shrink-0 border border-border-tertiary-50 cursor-pointer hover:opacity-80 transition-opacity"
|
||
@click.stop="playSong(song)"
|
||
>
|
||
<img
|
||
v-if="song.cover"
|
||
:src="convertToHttps(song.cover)"
|
||
class="w-full h-full object-cover"
|
||
referrerpolicy="no-referrer"
|
||
loading="lazy"
|
||
alt=""
|
||
/>
|
||
<div
|
||
v-else
|
||
class="w-full h-full flex items-center justify-center text-text-disabled"
|
||
>
|
||
<Music2 class="w-6 h-6 opacity-50" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
||
<div class="flex items-center gap-2 min-w-0">
|
||
<h4
|
||
class="font-bold text-text-primary text-sm truncate flex items-center gap-2 min-w-0"
|
||
>
|
||
<span
|
||
v-if="isBilibiliSong(song)"
|
||
class="text-text-primary flex items-center gap-1 text-left truncate"
|
||
>
|
||
<span class="truncate">{{ song.title }}</span>
|
||
</span>
|
||
<span v-else class="truncate">{{ song.title }}</span>
|
||
|
||
<!-- 歌单来源标签 -->
|
||
<span
|
||
v-if="isPlaylistFilterActive && playlistNamesMap[song.musicId]"
|
||
class="flex items-center gap-1 flex-shrink-0"
|
||
>
|
||
<span
|
||
v-for="(playlistName, idx) in playlistNamesMap[song.musicId]"
|
||
:key="idx"
|
||
class="px-1.5 py-[2px] bg-primary-10 text-primary rounded text-[9px] border border-primary-20 truncate max-w-[100px] font-normal leading-none"
|
||
:title="playlistName"
|
||
>
|
||
{{ playlistName }}
|
||
</span>
|
||
</span>
|
||
</h4>
|
||
<button
|
||
v-if="song.hasSubmissionNote && song.submissionNote"
|
||
class="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 flex-shrink-0"
|
||
:title="locale.viewRemark"
|
||
@click.stop="openSubmissionRemark(song)"
|
||
>
|
||
<MessageSquare :size="12" />
|
||
</button>
|
||
<span
|
||
v-if="song.cardCodeId"
|
||
class="inline-flex items-center rounded-md border border-warning-20 bg-warning-10 px-1.5 py-0.5 text-[9px] font-bold text-warning whitespace-nowrap flex-shrink-0"
|
||
:title="locale.cardPending"
|
||
>
|
||
{{ locale.cardPending }}
|
||
</span>
|
||
<span
|
||
v-if="song.hasSubmissionNote && song.submissionNote"
|
||
class="text-xs text-primary-80 truncate max-w-[150px] cursor-pointer hover:text-primary transition-colors"
|
||
:title="locale.viewRemark"
|
||
@click.stop="openSubmissionRemark(song)"
|
||
>
|
||
{{
|
||
song.submissionNote.length > 25
|
||
? song.submissionNote.substring(0, 25) + '...'
|
||
: song.submissionNote
|
||
}}
|
||
</span>
|
||
</div>
|
||
<div class="text-xs text-text-tertiary truncate flex items-center gap-1.5">
|
||
<span>{{ song.artist }}</span>
|
||
<!-- 时长显示 / 行内编辑 -->
|
||
<span
|
||
v-if="song.durationSeconds && !editingDuration[song.id]"
|
||
:class="[
|
||
'shrink-0 px-1 rounded transition-colors cursor-pointer',
|
||
durationRefreshStatus[song.id] === 'success'
|
||
? 'text-success bg-success-10'
|
||
: durationRefreshStatus[song.id] === 'error'
|
||
? 'text-error bg-error-10'
|
||
: 'text-text-disabled hover:text-text-secondary hover:bg-bg-quaternary'
|
||
]"
|
||
:title="locale.messages?.editDuration || '点击编辑时长'"
|
||
@click.stop="startEditDuration(song)"
|
||
>{{ formatDuration(song.durationSeconds) }}</span>
|
||
<input
|
||
v-else-if="editingDuration[song.id]"
|
||
ref="editingDurationInput"
|
||
v-model="editingDurationValue"
|
||
type="text"
|
||
inputmode="text"
|
||
pattern="[0-9:]*"
|
||
class="w-20 text-[11px] font-mono text-center bg-bg-primary border border-primary rounded px-1 py-0.5 text-text-primary focus:outline-none focus:border-primary shrink-0"
|
||
:placeholder="locale.messages?.durationInputPlaceholder || '分:秒'"
|
||
@focusout="saveDurationEdit(song)"
|
||
@keydown="handleDurationKeydown($event, song)"
|
||
>
|
||
</div>
|
||
<div class="text-[10px] text-text-tertiary truncate flex items-center gap-1">
|
||
<span>{{ song.requester }}</span>
|
||
<span v-if="song.requesterGrade || song.grade" class="text-text-disabled">|</span>
|
||
<span v-if="song.requesterGrade || song.grade">
|
||
{{ song.requesterGrade || song.grade }}
|
||
{{ song.requesterClass || song.class }}
|
||
</span>
|
||
<span
|
||
v-if="song.preferredPlayTimeId"
|
||
class="ml-1 px-1.5 py-0.5 bg-info-10 text-info rounded text-[9px] border border-info-20 whitespace-nowrap"
|
||
>
|
||
{{ callLocale('preferredPlayTime', `期望: ${getPlayTimeName(song.preferredPlayTimeId)}`, getPlayTimeName(song.preferredPlayTimeId)) }}
|
||
</span>
|
||
<span
|
||
v-if="activeTab === 'pool' && song.addedByName"
|
||
class="ml-1 px-1.5 py-0.5 bg-primary-10 text-primary rounded text-[9px] border border-primary-20 whitespace-nowrap"
|
||
>
|
||
{{ locale.addedBy }} {{ song.addedByName }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<!-- 普通模式:投票数 -->
|
||
<div
|
||
v-if="activeTab !== 'replay'"
|
||
class="flex items-center gap-1 text-[10px] font-bold text-text-tertiary bg-bg-primary-50 px-2 py-1 rounded-md border border-border-secondary-50"
|
||
>
|
||
<Heart class="w-3 h-3 text-error-50" />
|
||
{{ song.voteCount || 0 }}
|
||
</div>
|
||
|
||
<!-- 重播模式:查看按钮 -->
|
||
<button
|
||
v-if="activeTab === 'replay'"
|
||
class="px-3 py-1.5 rounded-lg bg-primary-10 hover:bg-primary-20 text-primary border border-primary-20 text-[10px] font-bold transition-colors"
|
||
@click.stop="openReplayModal(song)"
|
||
>
|
||
{{ locale.view }}
|
||
</button>
|
||
|
||
<!-- 重播模式:拒绝按钮(仅移动端) -->
|
||
<button
|
||
v-if="activeTab === 'replay'"
|
||
class="lg:hidden p-1.5 rounded-lg bg-error-10 hover:bg-error-20 text-error border border-error-20 transition-colors flex items-center justify-center"
|
||
:title="locale.rejectRequest"
|
||
@click.stop="rejectReplayRequest(song.id)"
|
||
>
|
||
<CloseIcon class="w-3.5 h-3.5" />
|
||
</button>
|
||
|
||
<!-- 待排库:加入备选池按钮 -->
|
||
<button
|
||
v-if="activeTab === 'normal' || activeTab === 'all' || activeTab === 'replay'"
|
||
class="hidden lg:flex p-1.5 rounded-lg bg-info-10 border border-info-20 text-info hover:bg-info-20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-info-10"
|
||
:disabled="poolSongIds.has(song.id)"
|
||
:title="poolSongIds.has(song.id) ? locale.alreadyInPool : locale.addSingleToPool"
|
||
@click.stop="addSingleToPool(song.id)"
|
||
>
|
||
<FolderPlus class="w-3.5 h-3.5" />
|
||
</button>
|
||
|
||
<!-- 备选池:移除按钮(桌面) -->
|
||
<button
|
||
v-if="activeTab === 'pool'"
|
||
class="hidden lg:flex p-1.5 rounded-lg bg-error-10 border border-error-20 text-error hover:bg-error-20 transition-colors"
|
||
:title="locale.removeFromPool"
|
||
@click.stop="removeFromPool(song.songId)"
|
||
>
|
||
<Trash2 class="w-3.5 h-3.5" />
|
||
</button>
|
||
|
||
<!-- 移动端加入备选池按钮 -->
|
||
<button
|
||
class="flex items-center justify-center lg:hidden p-2 rounded-full bg-info-10 text-info hover:bg-info-20 active:scale-95 transition-all flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
v-if="activeTab === 'normal' || activeTab === 'all' || activeTab === 'replay'"
|
||
:disabled="poolSongIds.has(song.id)"
|
||
:title="poolSongIds.has(song.id) ? locale.alreadyInPool : locale.addSingleToPool"
|
||
@click.stop="addSingleToPool(song.id)"
|
||
>
|
||
<FolderPlus class="w-5 h-5" />
|
||
</button>
|
||
|
||
<!-- 备选池:加入排期按钮 -->
|
||
<button
|
||
class="flex items-center justify-center lg:hidden p-2 rounded-full bg-primary-hover-20 text-primary hover:bg-primary-hover-30 active:scale-95 transition-all flex-shrink-0"
|
||
v-if="activeTab === 'pool'"
|
||
:title="locale.addToSchedule"
|
||
@click.stop="addSongToSchedule(song)"
|
||
>
|
||
<Plus class="w-5 h-5" />
|
||
</button>
|
||
|
||
<!-- 备选池:移除按钮(移动端) -->
|
||
<button
|
||
class="flex items-center justify-center lg:hidden p-2 rounded-full bg-error-20 text-error hover:bg-error-30 active:scale-95 transition-all flex-shrink-0"
|
||
v-if="activeTab === 'pool'"
|
||
:title="locale.removeFromPool"
|
||
@click.stop="removeFromPool(song.songId)"
|
||
>
|
||
<Trash2 class="w-5 h-5" />
|
||
</button>
|
||
|
||
<!-- 非备选池:加入排期按钮(移动端) -->
|
||
<button
|
||
class="flex items-center justify-center lg:hidden p-2 rounded-full bg-primary-hover-20 text-primary hover:bg-primary-hover-30 active:scale-95 transition-all flex-shrink-0"
|
||
v-if="activeTab !== 'pool'"
|
||
:title="locale.addToSchedule"
|
||
@click.stop="addSongToSchedule(song)"
|
||
>
|
||
<Plus class="w-5 h-5" />
|
||
</button>
|
||
|
||
<!-- 刷新时长按钮 -->
|
||
<button
|
||
class="p-1.5 rounded-lg bg-bg-primary border border-border-secondary text-text-disabled hover:text-primary transition-colors"
|
||
:title="locale.refreshDuration"
|
||
:disabled="refreshingDuration[song.id]"
|
||
@click.stop="refreshDuration(song)"
|
||
>
|
||
<RefreshCcw class="w-3.5 h-3.5" :class="{ 'animate-spin': refreshingDuration[song.id] }" />
|
||
</button>
|
||
|
||
<!-- 菜单按钮 -->
|
||
<button
|
||
type="button"
|
||
class="p-1.5 rounded-lg bg-bg-primary border border-border-secondary text-text-disabled hover:text-text-tertiary transition-colors"
|
||
@click="openContextMenu($event, 'left', song)"
|
||
>
|
||
<MoreVertical class="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div
|
||
v-if="filteredUnscheduledSongs.length === 0"
|
||
class="h-[300px] flex flex-col items-center justify-center text-text-primary"
|
||
>
|
||
<div v-if="searchQuery" class="flex flex-col items-center">
|
||
<Search class="w-8 h-8 mb-2 opacity-20" />
|
||
<p class="text-[10px] font-black uppercase tracking-widest">{{ locale.emptySearch }}</p>
|
||
</div>
|
||
<div v-else-if="activeTab === 'pool'" class="flex flex-col items-center">
|
||
<FolderPlus class="w-8 h-8 mb-2 opacity-20" />
|
||
<p class="text-[10px] font-black uppercase tracking-widest">{{ locale.poolEmptyHint }}</p>
|
||
</div>
|
||
<div v-else class="flex flex-col items-center">
|
||
<ListMusic class="w-8 h-8 mb-2 opacity-20" />
|
||
<p class="text-[10px] font-black uppercase tracking-widest">{{ locale.emptySongs }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<Pagination
|
||
v-model:current-page="currentPage"
|
||
:total-pages="totalPages"
|
||
:total-items="allUnscheduledSongs.length"
|
||
:item-name="activeTab === 'pool' ? locale.poolItemName : locale.pendingSongItemName"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧:播放列表(播放顺序) -->
|
||
<div
|
||
v-show="mobileTab === 'scheduled' || isDesktop"
|
||
:class="[
|
||
'lg:col-span-8 flex flex-col space-y-4',
|
||
mobileTab === 'pending' ? 'hidden lg:flex' : 'flex'
|
||
]"
|
||
>
|
||
<div
|
||
class="hidden lg:flex flex-col xl:flex-row xl:items-center justify-between gap-4 px-1"
|
||
>
|
||
<div class="flex items-baseline gap-3">
|
||
<h3 class="text-lg font-black tracking-tight text-text-primary uppercase">{{ locale.playOrder }}</h3>
|
||
<span v-show="scheduledTotalDuration > 0" class="flex items-baseline gap-1.5 text-[11px] font-bold text-text-tertiary uppercase tracking-wider">
|
||
{{ locale.expectedTotalDuration }}
|
||
<span class="text-sm font-black text-primary">{{ formatDuration(scheduledTotalDuration) }}</span>
|
||
</span>
|
||
</div>
|
||
<div
|
||
class="flex flex-wrap items-center gap-2 p-1.5 bg-bg-secondary-50 border border-border-secondary-50 rounded-2xl"
|
||
>
|
||
<div class="flex gap-1">
|
||
<button
|
||
:disabled="
|
||
!hasChanges && localScheduledSongs.length === 0 && !hasUnpublishedDrafts
|
||
"
|
||
class="flex items-center justify-center p-2 bg-bg-primary border border-border-secondary hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
|
||
@click="saveDraft"
|
||
>
|
||
<Save class="w-3.5 h-3.5" />
|
||
<span
|
||
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-bg-tertiary text-[9px] text-text-secondary rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-border-tertiary"
|
||
>{{ locale.saveDraft }}</span
|
||
>
|
||
</button>
|
||
<button
|
||
:disabled="localScheduledSongs.length === 0"
|
||
class="flex items-center justify-center p-2 bg-bg-primary border border-border-secondary hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
|
||
@click="openDownloadDialog"
|
||
>
|
||
<Download class="w-3.5 h-3.5" />
|
||
<span
|
||
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-bg-tertiary text-[9px] text-text-secondary rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-border-tertiary"
|
||
>{{ locale.downloadSongs }}</span
|
||
>
|
||
</button>
|
||
<button
|
||
:disabled="localScheduledSongs.length === 0"
|
||
class="flex items-center justify-center p-2 bg-bg-primary border border-border-secondary hover:bg-bg-tertiary text-text-tertiary hover:text-success rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
|
||
@click="markAllAsPlayed"
|
||
>
|
||
<CheckCircle2 class="w-3.5 h-3.5" />
|
||
<span
|
||
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-bg-tertiary text-[9px] text-text-secondary rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-border-tertiary"
|
||
>{{ locale.markAllPlayed }}</span
|
||
>
|
||
</button>
|
||
<button
|
||
:disabled="refreshingAllDurations.running"
|
||
class="flex items-center justify-center p-2 bg-bg-primary border border-border-secondary hover:bg-bg-tertiary text-text-tertiary hover:text-primary rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
|
||
@click="refreshAllDurations('scheduled')"
|
||
>
|
||
<RefreshCcw
|
||
class="w-3.5 h-3.5"
|
||
:class="{ 'animate-spin': refreshingAllDurations.running }"
|
||
/>
|
||
<span
|
||
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-bg-tertiary text-[9px] text-text-secondary rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-border-tertiary"
|
||
>{{ locale.refreshPageDurations }}{{
|
||
refreshingAllDurations.running
|
||
? ` (${refreshingAllDurations.progress})`
|
||
: ''
|
||
}}</span
|
||
>
|
||
</button>
|
||
<button
|
||
:disabled="autoScheduleCandidates.length === 0"
|
||
class="flex items-center justify-center p-2 bg-bg-primary border border-border-secondary hover:bg-bg-tertiary text-text-tertiary hover:text-primary rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
|
||
@click="openAutoScheduleDialog"
|
||
>
|
||
<Sparkles class="w-3.5 h-3.5" />
|
||
<span
|
||
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-bg-tertiary text-[9px] text-text-secondary rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-border-tertiary"
|
||
>{{ locale.autoSchedule }}</span
|
||
>
|
||
</button>
|
||
<button
|
||
class="flex items-center justify-center p-2 bg-bg-primary border border-border-secondary hover:bg-bg-tertiary text-text-tertiary hover:text-info rounded-xl transition-all group relative"
|
||
@click="openMoveDateDialog"
|
||
>
|
||
<ArrowRight class="w-3.5 h-3.5" />
|
||
<span
|
||
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-bg-tertiary text-[9px] text-text-secondary rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-border-tertiary"
|
||
>{{ locale.moveDate }}</span
|
||
>
|
||
</button>
|
||
<button
|
||
class="flex items-center justify-center p-2 bg-bg-primary border border-border-secondary hover:bg-bg-tertiary text-text-tertiary hover:text-primary rounded-xl transition-all group relative"
|
||
@click="openCopyDateDialog"
|
||
>
|
||
<Copy class="w-3.5 h-3.5" />
|
||
<span
|
||
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-bg-tertiary text-[9px] text-text-secondary rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-border-tertiary"
|
||
>{{ locale.copyDate }}</span
|
||
>
|
||
</button>
|
||
<button
|
||
:disabled="localScheduledSongs.length === 0"
|
||
class="flex items-center justify-center p-2 bg-bg-primary border border-border-secondary hover:bg-bg-tertiary text-text-tertiary hover:text-error rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
|
||
@click="clearScheduleList"
|
||
>
|
||
<Trash2 class="w-3.5 h-3.5" />
|
||
<span
|
||
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-bg-tertiary text-[9px] text-text-secondary rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-border-tertiary"
|
||
>{{ locale.clearList }}</span
|
||
>
|
||
</button>
|
||
</div>
|
||
<div class="h-6 w-[1px] bg-bg-tertiary mx-1" />
|
||
<button
|
||
:disabled="!canPublish"
|
||
class="flex items-center gap-2 px-4 py-2 bg-success-10 hover:bg-success-20 text-success text-[10px] font-black rounded-xl border border-success-20 transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
|
||
@click="publishSchedule"
|
||
>
|
||
<Send class="w-3 h-3" /> {{ locale.publishSchedule }}
|
||
</button>
|
||
<button
|
||
:disabled="!hasChanges"
|
||
class="flex items-center gap-2 px-5 py-2 bg-primary-hover hover:bg-primary text-text-primary text-[10px] font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
|
||
@click="saveSequence"
|
||
>
|
||
<FileBadge class="w-3.5 h-3.5" /> {{ locale.saveAndPublish }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
ref="sequenceList"
|
||
:class="[
|
||
'sequence-list flex-1 border-2 border-dashed rounded-[2rem] p-2 md:p-3 min-h-[400px] transition-colors duration-200',
|
||
isSequenceOver ? 'border-primary bg-primary-5' : 'border-border-secondary-80 bg-bg-secondary-20'
|
||
]"
|
||
@dragleave="handleSequenceDragLeave"
|
||
@dragover.prevent="handleDragOver"
|
||
@dragenter.prevent="isSequenceOver = true"
|
||
@drop.stop.prevent="dropToSequence"
|
||
>
|
||
<div
|
||
v-if="localScheduledSongs.length === 0"
|
||
class="flex flex-col items-center justify-center h-full py-12 text-text-primary"
|
||
>
|
||
<PlaySquare class="w-8 h-8 mb-4 opacity-20" />
|
||
<p class="text-[10px] font-black uppercase tracking-widest">{{ locale.addSongsHint }}</p>
|
||
</div>
|
||
|
||
<TransitionGroup class="space-y-2" name="schedule-list" tag="div">
|
||
<div
|
||
v-for="(schedule, index) in localScheduledSongs"
|
||
:key="schedule.id"
|
||
:class="[
|
||
'scheduled-song relative group bg-bg-secondary border border-border-secondary-50 rounded-xl p-3 hover:border-border-tertiary transition-all select-none',
|
||
dragOverIndex === index ? 'border-t-2 border-t-primary' : '',
|
||
schedule.isDraft ? 'border-warning-30 bg-warning-5' : '',
|
||
schedule.song && (schedule.song.cardCodeId || schedule.song.usedCardCode)
|
||
? 'border-warning-30 bg-warning-5'
|
||
: ''
|
||
]"
|
||
:data-schedule-id="schedule.id"
|
||
:draggable="true"
|
||
@dragend="dragEnd"
|
||
@dragleave="handleDragLeave"
|
||
@dragstart="dragScheduleStart($event, schedule)"
|
||
@touchend="handleTouchEnd"
|
||
@touchmove="handleTouchMove"
|
||
@touchstart="handleTouchStart($event, schedule, 'schedule')"
|
||
@dragover.prevent
|
||
@dragenter.prevent="handleDragEnter($event, index)"
|
||
@drop.stop.prevent="dropReorder($event, index)"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<div
|
||
class="flex flex-col items-center justify-center w-10 h-10 rounded-lg bg-bg-primary-50 border border-border-secondary text-text-tertiary font-black text-xs flex-shrink-0"
|
||
>
|
||
<span class="text-[8px] text-text-disabled uppercase leading-none mb-0.5">{{ locale.positionShort }}</span>
|
||
<span class="text-sm text-text-secondary leading-none">{{
|
||
index + 1 < 10 ? '0' + (index + 1) : index + 1
|
||
}}</span>
|
||
</div>
|
||
|
||
<!-- 封面图片 -->
|
||
<div
|
||
class="relative w-10 h-10 rounded-lg overflow-hidden bg-bg-tertiary flex-shrink-0 border border-border-tertiary-50 cursor-pointer hover:opacity-80 transition-opacity"
|
||
@click.stop="playSong(schedule.song)"
|
||
>
|
||
<img
|
||
v-if="schedule.song.cover"
|
||
:src="convertToHttps(schedule.song.cover)"
|
||
class="w-full h-full object-cover"
|
||
referrerpolicy="no-referrer"
|
||
loading="lazy"
|
||
alt=""
|
||
/>
|
||
<div
|
||
v-else
|
||
class="w-full h-full flex items-center justify-center text-text-disabled"
|
||
>
|
||
<Music2 class="w-5 h-5 opacity-50" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
||
<div class="flex items-center gap-2 min-w-0">
|
||
<h4 class="font-bold text-text-primary text-sm truncate min-w-0">
|
||
{{ schedule.song.title }}
|
||
</h4>
|
||
<button
|
||
v-if="schedule.song.hasSubmissionNote && schedule.song.submissionNote"
|
||
class="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 flex-shrink-0"
|
||
:title="locale.viewRemark"
|
||
@click.stop="openSubmissionRemark(schedule.song, schedule.replayRequestId)"
|
||
>
|
||
<MessageSquare :size="12" />
|
||
</button>
|
||
<span
|
||
v-if="schedule.song.hasSubmissionNote && schedule.song.submissionNote"
|
||
class="text-xs text-primary-80 truncate max-w-[150px] cursor-pointer hover:text-primary transition-colors"
|
||
:title="locale.viewRemark"
|
||
@click.stop="openSubmissionRemark(schedule.song, schedule.replayRequestId)"
|
||
>
|
||
{{
|
||
schedule.song.submissionNote.length > 25
|
||
? schedule.song.submissionNote.substring(0, 25) + '...'
|
||
: schedule.song.submissionNote
|
||
}}
|
||
</span>
|
||
<!-- 重播标识 -->
|
||
<span
|
||
v-if="schedule.replayRequestId != null"
|
||
class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-primary-10 text-primary border border-primary-20 uppercase tracking-wider whitespace-nowrap flex-shrink-0 flex items-center gap-1"
|
||
:title="locale.replaySong"
|
||
>
|
||
<Icon name="repeat" :size="10" class-name="flex-shrink-0" />{{ locale.replay }}
|
||
</span>
|
||
<span
|
||
v-if="schedule.isDraft"
|
||
class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-warning-10 text-warning border border-warning-20 uppercase tracking-wider whitespace-nowrap flex-shrink-0"
|
||
>{{ locale.draft }}</span
|
||
>
|
||
<!-- 点歌券徽章(已使用点歌券投稿的歌曲在排期中高亮显示) -->
|
||
<span
|
||
v-if="schedule.song.cardCodeId || schedule.song.usedCardCode"
|
||
class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-warning-10 text-warning border border-warning-20 uppercase tracking-wider whitespace-nowrap flex-shrink-0"
|
||
:title="locale.cardPending"
|
||
>
|
||
{{ locale.cardCode }}
|
||
</span>
|
||
</div>
|
||
<div class="text-xs text-text-tertiary truncate flex items-center gap-1.5">
|
||
<span>{{ schedule.song.artist }}</span>
|
||
<span
|
||
v-if="schedule.song.durationSeconds && !editingDuration[schedule.song.id]"
|
||
:class="[
|
||
'shrink-0 px-1 rounded transition-colors cursor-pointer',
|
||
durationRefreshStatus[schedule.song.id] === 'success'
|
||
? 'text-success bg-success-10'
|
||
: durationRefreshStatus[schedule.song.id] === 'error'
|
||
? 'text-error bg-error-10'
|
||
: 'text-text-disabled hover:text-text-secondary hover:bg-bg-quaternary'
|
||
]"
|
||
:title="locale.messages?.editDuration || '点击编辑时长'"
|
||
@click.stop="startEditDuration(schedule.song)"
|
||
>{{ formatDuration(schedule.song.durationSeconds) }}</span>
|
||
<input
|
||
v-else-if="editingDuration[schedule.song.id]"
|
||
ref="editingDurationInput"
|
||
v-model="editingDurationValue"
|
||
type="text"
|
||
inputmode="text"
|
||
pattern="[0-9:]*"
|
||
class="w-20 text-[11px] font-mono text-center bg-bg-primary border border-primary rounded px-1 py-0.5 text-text-primary focus:outline-none focus:border-primary shrink-0"
|
||
:placeholder="locale.messages?.durationInputPlaceholder || '分:秒'"
|
||
@focusout="saveDurationEdit(schedule.song)"
|
||
@keydown="handleDurationKeydown($event, schedule.song)"
|
||
>
|
||
</div>
|
||
<div class="text-[10px] text-text-tertiary truncate flex items-center gap-1">
|
||
<span
|
||
v-if="schedule.replayRequestId != null"
|
||
:title="
|
||
(locale.replayApplicants || '重播申请人:') +
|
||
(schedule.song.replayRequesters || [])
|
||
.map((r) => r.displayName || r.name)
|
||
.join('、')
|
||
"
|
||
>
|
||
{{ locale.applicant }}{{
|
||
(schedule.song.replayRequesters || [])
|
||
.slice(0, 2)
|
||
.map((r) => r.displayName || r.name)
|
||
.join('、')
|
||
}}{{
|
||
schedule.song.replayRequestCount > 2
|
||
? locale.andMoreApplicants(schedule.song.replayRequestCount)
|
||
: ''
|
||
}}
|
||
</span>
|
||
<span v-else>{{ schedule.song.requester }}</span>
|
||
<span
|
||
v-if="schedule.song.requesterGrade || schedule.song.grade"
|
||
class="text-text-disabled"
|
||
>|</span
|
||
>
|
||
<span v-if="schedule.song.requesterGrade || schedule.song.grade">
|
||
{{ schedule.song.requesterGrade || schedule.song.grade }}
|
||
{{ schedule.song.requesterClass || schedule.song.class }}
|
||
</span>
|
||
<span
|
||
v-if="schedule.song.preferredPlayTimeId"
|
||
class="ml-1 px-1.5 py-0.5 bg-info-10 text-info rounded text-[9px] border border-info-20 whitespace-nowrap"
|
||
>
|
||
{{ callLocale('preferredPlayTime', `期望: ${getPlayTimeName(schedule.song.preferredPlayTimeId)}`, getPlayTimeName(schedule.song.preferredPlayTimeId)) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<button
|
||
v-if="schedule.isDraft"
|
||
class="p-1.5 rounded-lg bg-success-10 hover:bg-success-20 text-success border border-success-20 transition-colors"
|
||
:title="locale.publishThisDraft"
|
||
@click="publishSingleDraft(schedule)"
|
||
>
|
||
<Send class="w-3.5 h-3.5" />
|
||
</button>
|
||
|
||
<!-- 移动端删除按钮 -->
|
||
<button
|
||
class="lg:hidden p-2 rounded-full bg-error-20 text-error hover:bg-error-30 active:scale-95 transition-all flex-shrink-0 flex items-center justify-center"
|
||
@click.stop="removeSongFromSchedule(schedule)"
|
||
>
|
||
<Minus class="w-5 h-5" />
|
||
</button>
|
||
|
||
<!-- 刷新时长按钮 -->
|
||
<button
|
||
class="p-1.5 rounded-lg bg-bg-primary border border-border-secondary text-text-disabled hover:text-primary hover:border-primary-30 transition-all duration-200"
|
||
:class="{
|
||
'bg-primary-10 border-primary-30 text-primary shadow-[0_0_0_3px_var(--primary-glow)]': refreshingDuration[schedule.song.id]
|
||
}"
|
||
:title="locale.refreshDuration"
|
||
:disabled="refreshingDuration[schedule.song.id]"
|
||
@click.stop="refreshDuration(schedule.song)"
|
||
>
|
||
<RefreshCcw class="w-3.5 h-3.5" :class="{ 'animate-spin': refreshingDuration[schedule.song.id] }" />
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
class="p-1.5 rounded-lg bg-bg-primary border border-border-secondary text-text-disabled hover:text-text-tertiary transition-colors"
|
||
@click="openContextMenu($event, 'right', schedule)"
|
||
>
|
||
<MoreVertical class="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</TransitionGroup>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 移动端底部操作栏 -->
|
||
<div
|
||
class="lg:hidden fixed bottom-0 left-0 right-0 z-40 p-3 bg-bg-primary-90 backdrop-blur-xl border-t border-border-secondary flex items-center gap-3 pb-6"
|
||
>
|
||
<div class="w-[148px] overflow-x-auto scrollbar-hide">
|
||
<div class="flex items-center gap-2 w-max">
|
||
<button
|
||
class="w-11 h-11 shrink-0 bg-bg-secondary border border-border-secondary text-text-tertiary rounded-xl flex items-center justify-center active:scale-95 transition-all"
|
||
@click="openDownloadDialog"
|
||
>
|
||
<Download class="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
class="w-11 h-11 shrink-0 bg-bg-secondary border border-border-secondary text-text-tertiary rounded-xl flex items-center justify-center active:scale-95 transition-all"
|
||
@click="saveDraft"
|
||
>
|
||
<Save class="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
class="w-11 h-11 shrink-0 bg-bg-secondary border border-border-secondary text-success rounded-xl flex items-center justify-center active:scale-95 transition-all"
|
||
@click="markAllAsPlayed"
|
||
>
|
||
<CheckCircle2 class="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
class="w-11 h-11 shrink-0 bg-bg-secondary border border-border-secondary text-info rounded-xl flex items-center justify-center active:scale-95 transition-all"
|
||
@click="openMoveDateDialog"
|
||
>
|
||
<ArrowRight class="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
class="w-11 h-11 shrink-0 bg-bg-secondary border border-border-secondary text-primary rounded-xl flex items-center justify-center active:scale-95 transition-all"
|
||
@click="openCopyDateDialog"
|
||
>
|
||
<Copy class="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
class="w-11 h-11 shrink-0 bg-bg-secondary border border-border-secondary text-error rounded-xl flex items-center justify-center active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||
:disabled="localScheduledSongs.length === 0"
|
||
@click="clearScheduleList"
|
||
>
|
||
<Trash2 class="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
class="w-11 h-11 shrink-0 bg-bg-secondary border border-border-secondary text-info rounded-xl flex items-center justify-center active:scale-95 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||
v-if="activeTab === 'normal' || activeTab === 'all' || activeTab === 'replay'"
|
||
:disabled="filteredUnscheduledSongs.filter((song) => !poolSongIds.has(song.id)).length === 0"
|
||
:title="locale.addCurrentPage"
|
||
@click="moveAllToPool"
|
||
>
|
||
<FolderPlus class="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
class="w-11 h-11 shrink-0 bg-bg-secondary border border-border-secondary text-primary rounded-xl flex items-center justify-center active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||
:disabled="!canPublish"
|
||
:title="locale.publishOnly"
|
||
@click="publishSchedule"
|
||
>
|
||
<Send class="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
class="w-11 h-11 shrink-0 bg-bg-secondary border border-border-secondary text-primary rounded-xl flex items-center justify-center active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||
:disabled="refreshingAllDurations.running"
|
||
:title="refreshingAllDurations.running ? `${locale.refreshPageDurations} (${refreshingAllDurations.progress})` : locale.refreshPageDurations"
|
||
@click="refreshAllDurations"
|
||
>
|
||
<RefreshCcw class="w-5 h-5" :class="{ 'animate-spin': refreshingAllDurations.running }" />
|
||
</button>
|
||
<button
|
||
class="w-11 h-11 shrink-0 bg-bg-secondary border border-border-secondary text-primary rounded-xl flex items-center justify-center active:scale-95 transition-all"
|
||
:title="locale.autoSchedule"
|
||
@click="openAutoScheduleDialog"
|
||
>
|
||
<Sparkles class="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主要操作 -->
|
||
<button
|
||
:disabled="!hasChanges"
|
||
class="flex-1 py-3 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black uppercase tracking-widest rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||
@click="saveSequence"
|
||
>
|
||
<FileBadge class="w-4 h-4" /> {{ locale.saveAndPublish }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 确认对话框 -->
|
||
<ConfirmDialog
|
||
:confirm-text="confirmDialogConfirmText"
|
||
:loading="loading"
|
||
:message="confirmDialogMessage"
|
||
:show="showConfirmDialog"
|
||
:title="confirmDialogTitle"
|
||
:type="confirmDialogType"
|
||
:cancel-text="locale.cancel"
|
||
@close="showConfirmDialog = false"
|
||
@confirm="handleConfirm"
|
||
/>
|
||
|
||
<!-- 下载对话框 -->
|
||
<SongDownloadDialog
|
||
:show="showDownloadDialog"
|
||
:songs="localScheduledSongs"
|
||
@close="showDownloadDialog = false"
|
||
/>
|
||
|
||
<div
|
||
v-if="showMoveDateDialog"
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-bg-primary-60 backdrop-blur-sm"
|
||
>
|
||
<div
|
||
class="bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl w-full max-w-sm overflow-hidden"
|
||
@click.stop
|
||
>
|
||
<div class="flex items-center justify-between p-4 border-b border-border-secondary">
|
||
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest">{{ locale.moveDateTitle }}</h3>
|
||
<button
|
||
class="text-text-tertiary hover:text-text-secondary transition-colors"
|
||
@click="showMoveDateDialog = false"
|
||
>
|
||
<CloseIcon class="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
<div class="p-6 space-y-4">
|
||
<div class="text-xs text-text-tertiary">{{ locale.currentDate(selectedDate) }}</div>
|
||
<input
|
||
v-model="moveTargetDate"
|
||
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-text-primary focus:outline-none focus:border-info transition-colors"
|
||
type="date"
|
||
/>
|
||
<div class="flex gap-3">
|
||
<button
|
||
class="flex-1 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
|
||
@click="showMoveDateDialog = false"
|
||
>
|
||
{{ locale.cancel }}
|
||
</button>
|
||
<button
|
||
class="flex-1 py-3 bg-info hover:bg-info text-text-primary text-xs font-bold rounded-xl shadow-lg shadow-[var(--info-glow-20)] transition-colors uppercase tracking-wider"
|
||
@click="confirmMoveDate"
|
||
>
|
||
{{ locale.nextStep }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="showCopyDateDialog"
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-bg-primary-60 backdrop-blur-sm"
|
||
>
|
||
<div
|
||
class="bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl w-full max-w-sm overflow-hidden"
|
||
@click.stop
|
||
>
|
||
<div class="flex items-center justify-between p-4 border-b border-border-secondary">
|
||
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest">{{ locale.copyDateTitle }}</h3>
|
||
<button
|
||
class="text-text-tertiary hover:text-text-secondary transition-colors"
|
||
@click="showCopyDateDialog = false"
|
||
>
|
||
<CloseIcon class="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
<div class="p-4 space-y-3">
|
||
<!-- 复制方式选择器 -->
|
||
<div class="flex items-center gap-2 p-1 bg-bg-primary border border-border-secondary rounded-xl">
|
||
<button
|
||
:class="[
|
||
'flex-1 py-2 rounded-lg text-[11px] font-black uppercase tracking-widest transition-all border',
|
||
copyMode === 'single'
|
||
? 'bg-primary-hover border-primary text-text-primary shadow-md shadow-[var(--primary-glow)]'
|
||
: 'border-transparent text-text-tertiary hover:text-text-secondary'
|
||
]"
|
||
@click="switchCopyMode('single')"
|
||
>
|
||
{{ locale.singleDayCopy }}
|
||
</button>
|
||
<button
|
||
:class="[
|
||
'flex-1 py-2 rounded-lg text-[11px] font-black uppercase tracking-widest transition-all border',
|
||
copyMode === 'cycle'
|
||
? 'bg-primary-hover border-primary text-text-primary shadow-md shadow-[var(--primary-glow)]'
|
||
: 'border-transparent text-text-tertiary hover:text-text-secondary'
|
||
]"
|
||
@click="switchCopyMode('cycle')"
|
||
>
|
||
{{ locale.cycleCopy }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 源日期 -->
|
||
<div>
|
||
<div class="text-[10px] font-bold uppercase tracking-widest text-text-secondary mb-2">
|
||
{{ copyMode === 'single' ? locale.cycleCopySourceDate : locale.copyDateSourceRange }}
|
||
</div>
|
||
<!-- 单天模式:只显示一个源日期 -->
|
||
<div v-if="copyMode === 'single'" class="space-y-1.5">
|
||
<div class="gap-3 flex items-center">
|
||
<span class="text-[9px] text-text-disabled w-16 shrink-0 uppercase tracking-wider">{{ locale.copyDateRangeStart }}</span>
|
||
<input
|
||
v-model="copyFromStart"
|
||
class="flex-1 bg-bg-primary border border-border-secondary rounded-xl px-3 py-2.5 text-text-primary focus:outline-none focus:border-primary transition-colors"
|
||
type="date"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<!-- 周期模式:显示源日期范围 -->
|
||
<div v-else class="space-y-1.5">
|
||
<div class="gap-3 flex items-center">
|
||
<span class="text-[9px] text-text-disabled w-16 shrink-0 uppercase tracking-wider">{{ locale.copyDateRangeStart }}</span>
|
||
<input
|
||
v-model="copyFromStart"
|
||
class="flex-1 bg-bg-primary border border-border-secondary rounded-xl px-3 py-2.5 text-text-primary focus:outline-none focus:border-primary transition-colors"
|
||
type="date"
|
||
/>
|
||
</div>
|
||
<div class="gap-3 flex items-center">
|
||
<span class="text-[9px] text-text-disabled w-16 shrink-0 uppercase tracking-wider">{{ locale.copyDateRangeEnd }}</span>
|
||
<input
|
||
v-model="copyFromEnd"
|
||
class="flex-1 bg-bg-primary border border-border-secondary rounded-xl px-3 py-2.5 text-text-primary focus:outline-none focus:border-primary transition-colors"
|
||
type="date"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 目标日期 -->
|
||
<div>
|
||
<div class="text-[10px] font-bold uppercase tracking-widest text-text-secondary mb-2">
|
||
{{ locale.copyDateTargetRange }}
|
||
</div>
|
||
<!-- 单天模式:只显示一个目标日期 -->
|
||
<div v-if="copyMode === 'single'" class="space-y-1.5">
|
||
<div class="gap-3 flex items-center">
|
||
<span class="text-[9px] text-text-disabled w-16 shrink-0 uppercase tracking-wider">{{ locale.cycleCopyTargetSingleDate }}</span>
|
||
<input
|
||
v-model="copySingleTargetDate"
|
||
class="flex-1 bg-bg-primary border border-border-secondary rounded-xl px-3 py-2.5 text-text-primary focus:outline-none focus:border-primary transition-colors"
|
||
type="date"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<!-- 周期模式:显示目标日期范围 -->
|
||
<div v-else class="space-y-1.5">
|
||
<div class="gap-3 flex items-center">
|
||
<span class="text-[9px] text-text-disabled w-16 shrink-0 uppercase tracking-wider">{{ locale.cycleCopyTargetStart }}</span>
|
||
<input
|
||
v-model="copyToStart"
|
||
class="flex-1 bg-bg-primary border border-border-secondary rounded-xl px-3 py-2.5 text-text-primary focus:outline-none focus:border-primary transition-colors"
|
||
type="date"
|
||
/>
|
||
</div>
|
||
<div class="gap-3 flex items-center">
|
||
<span class="text-[9px] text-text-disabled w-16 shrink-0 uppercase tracking-wider">{{ locale.cycleCopyTargetEnd }}</span>
|
||
<input
|
||
v-model="copyToEnd"
|
||
class="flex-1 bg-bg-primary border border-border-secondary rounded-xl px-3 py-2.5 text-text-primary focus:outline-none focus:border-primary transition-colors"
|
||
type="date"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-3">
|
||
<button
|
||
class="flex-1 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
|
||
@click="showCopyDateDialog = false"
|
||
>
|
||
{{ locale.cancel }}
|
||
</button>
|
||
<button
|
||
class="flex-1 py-3 bg-primary-hover hover:bg-primary text-text-primary text-xs font-bold rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-colors uppercase tracking-wider"
|
||
@click="confirmCopyDate"
|
||
>
|
||
{{ locale.nextStep }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 重播申请详情弹窗 -->
|
||
<div
|
||
v-if="showReplayModal"
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-bg-primary-60 backdrop-blur-sm"
|
||
@click="closeReplayModal"
|
||
>
|
||
<div
|
||
class="bg-bg-secondary border border-border-secondary rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"
|
||
@click.stop
|
||
>
|
||
<div class="flex items-center justify-between p-4 border-b border-border-secondary">
|
||
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest">
|
||
{{ locale.replayDetailTitle(replayModalTitle) }}
|
||
</h3>
|
||
<div class="flex items-center gap-3">
|
||
<button
|
||
class="px-3 py-1.5 bg-error-10 hover:bg-error-20 text-error border border-error-20 rounded-lg text-xs font-bold transition-colors"
|
||
@click="(rejectReplayRequest(replayModalSongId), closeReplayModal())"
|
||
>
|
||
{{ locale.rejectRequest }}
|
||
</button>
|
||
<button
|
||
class="text-text-tertiary hover:text-text-secondary transition-colors"
|
||
@click="closeReplayModal"
|
||
>
|
||
<CloseIcon class="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="p-0 overflow-y-auto max-h-[60vh]">
|
||
<div class="divide-y divide-panel-bg-dark-50">
|
||
<div
|
||
v-for="(req, idx) in replayModalRequests"
|
||
:key="idx"
|
||
class="flex items-center justify-between p-4 group"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<div
|
||
class="w-8 h-8 rounded-lg bg-bg-secondary border border-border-secondary flex items-center justify-center text-text-tertiary group-hover:text-primary transition-colors"
|
||
>
|
||
<User class="w-3.5 h-3.5" />
|
||
</div>
|
||
<div class="flex flex-col">
|
||
<span class="text-sm font-bold text-text-primary">{{ req.name }}</span>
|
||
<span v-if="req.grade" class="text-[10px] text-text-tertiary"
|
||
>{{ req.grade }}{{ req.class ? ` ${req.class}` : '' }}</span
|
||
>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="flex items-center gap-1.5 text-[10px] font-black text-text-disabled uppercase tracking-widest"
|
||
>
|
||
<Clock class="w-2.5 h-2.5" />
|
||
{{ formatDate(req.createdAt) }}
|
||
</div>
|
||
</div>
|
||
<div v-if="replayModalRequests.length === 0" class="py-10 text-center text-text-secondary">
|
||
<Info class="w-6 h-6 mx-auto mb-2 opacity-20" />
|
||
<p class="text-xs font-bold uppercase tracking-widest">{{ locale.noReplayDetails }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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')"
|
||
/>
|
||
|
||
<SchedulePlaylistFilterModal
|
||
:show="showPlaylistFilterModal"
|
||
@update:show="showPlaylistFilterModal = $event"
|
||
@apply="handlePlaylistFilterApply"
|
||
/>
|
||
|
||
<!-- 自动排期弹窗 -->
|
||
<div
|
||
v-if="showAutoScheduleDialog"
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-bg-primary-60 backdrop-blur-sm"
|
||
>
|
||
<div
|
||
class="bg-bg-secondary border border-border-secondary rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden mx-4"
|
||
@click.stop
|
||
>
|
||
<div class="flex items-center justify-between p-4 border-b border-border-secondary">
|
||
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest">{{ locale.autoScheduleTitle }}</h3>
|
||
<button
|
||
class="text-text-tertiary hover:text-text-secondary transition-colors"
|
||
@click="closeAutoScheduleDialog"
|
||
>
|
||
<CloseIcon class="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div class="p-5 space-y-4">
|
||
<!-- 输入区 -->
|
||
<div v-if="!autoScheduleResult.songs.length" class="space-y-4">
|
||
<div>
|
||
<label class="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-1.5">{{ locale.targetDuration }}</label>
|
||
<div class="flex gap-2">
|
||
<input
|
||
v-model.number="autoScheduleTargetMinutes"
|
||
type="number"
|
||
min="1"
|
||
:placeholder="locale.targetDurationPlaceholder"
|
||
class="flex-1 bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-text-primary focus:outline-none focus:border-primary transition-colors"
|
||
@keydown.enter="runAutoSchedule"
|
||
/>
|
||
<span class="flex items-center text-xs text-text-disabled px-1">min</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-1.5">{{ locale.targetSongCount }}</label>
|
||
<div class="flex gap-2">
|
||
<input
|
||
v-model.number="autoScheduleTargetSongCount"
|
||
type="number"
|
||
min="1"
|
||
step="1"
|
||
:placeholder="locale.targetSongCountPlaceholder"
|
||
class="flex-1 bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-text-primary focus:outline-none focus:border-primary transition-colors"
|
||
@keydown.enter="runAutoSchedule"
|
||
/>
|
||
<span class="flex items-center text-xs text-text-disabled px-1">{{ locale.songCountUnit }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-1.5">{{ locale.targetRequesterCount }}</label>
|
||
<div class="flex gap-2">
|
||
<input
|
||
v-model.number="autoScheduleTargetRequesterCount"
|
||
type="number"
|
||
min="1"
|
||
step="1"
|
||
:placeholder="locale.targetRequesterCountPlaceholder"
|
||
class="flex-1 bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-text-primary focus:outline-none focus:border-primary transition-colors"
|
||
@keydown.enter="runAutoSchedule"
|
||
/>
|
||
<span class="flex items-center text-xs text-text-disabled px-1">{{ locale.requesterCountUnit }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 工具栏 -->
|
||
<div class="flex items-center gap-2 p-2 bg-bg-secondary-50 border border-border-secondary rounded-xl">
|
||
<div class="flex items-center gap-2 flex-1">
|
||
<Lock class="w-3.5 h-3.5 text-text-tertiary shrink-0" />
|
||
<span class="text-[10px] font-bold text-text-secondary uppercase tracking-wider shrink-0">
|
||
{{ locale.fixExistingSelected }}
|
||
</span>
|
||
<button
|
||
class="relative inline-flex items-center w-10 h-6 rounded-full transition-colors shrink-0"
|
||
:class="autoScheduleFixExisting ? 'bg-primary' : 'bg-bg-tertiary'"
|
||
@click="autoScheduleFixExisting = !autoScheduleFixExisting"
|
||
>
|
||
<span
|
||
class="absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform"
|
||
:class="autoScheduleFixExisting ? 'translate-x-4' : 'translate-x-0.5'"
|
||
/>
|
||
</button>
|
||
<span
|
||
v-if="autoScheduleFixExisting && autoScheduleScheduledSeconds > 0"
|
||
class="text-[10px] font-bold text-primary"
|
||
>
|
||
{{ locale.fixExistingCount(localScheduledSongs.length) }} ·
|
||
{{ locale.fixExistingRemaining(autoScheduleTargetMinutes || 0, autoScheduleScheduledSeconds) }}
|
||
</span>
|
||
<span v-else-if="autoScheduleFixExisting && localScheduledSongs.length === 0" class="text-[10px] text-text-disabled">
|
||
{{ locale.fixExistingNone }}
|
||
</span>
|
||
</div>
|
||
<button
|
||
:disabled="refreshingAutoCandidates.running"
|
||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-[10px] font-bold border border-border-secondary text-text-tertiary hover:text-primary hover:border-primary-30 rounded-lg transition-all uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||
:title="locale.refreshCandidateDurations"
|
||
@click="refreshAutoCandidateDurations"
|
||
>
|
||
<RefreshCcw class="w-3.5 h-3.5" :class="{ 'animate-spin': refreshingAutoCandidates.running }" />
|
||
{{ locale.refreshCandidateDurations }}
|
||
<span v-if="refreshingAutoCandidates.running" class="text-text-disabled ml-1">
|
||
{{ refreshingAutoCandidates.progress }}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-1.5">{{ locale.scheduleDirection }}</label>
|
||
<div class="flex gap-2">
|
||
<button
|
||
:class="[
|
||
'flex-1 py-2.5 rounded-xl text-xs font-bold border transition-all uppercase tracking-wider',
|
||
autoScheduleDirection === 'under'
|
||
? 'bg-primary-10 border-primary-30 text-primary'
|
||
: 'bg-bg-primary border-border-secondary text-text-secondary hover:border-border-tertiary'
|
||
]"
|
||
@click="autoScheduleDirection = 'under'"
|
||
>
|
||
{{ locale.directionUnder }}
|
||
</button>
|
||
<button
|
||
:class="[
|
||
'flex-1 py-2.5 rounded-xl text-xs font-bold border transition-all uppercase tracking-wider',
|
||
autoScheduleDirection === 'middle'
|
||
? 'bg-primary-10 border-primary-30 text-primary'
|
||
: 'bg-bg-primary border-border-secondary text-text-secondary hover:border-border-tertiary'
|
||
]"
|
||
@click="autoScheduleDirection = 'middle'"
|
||
>
|
||
{{ locale.directionMiddle }}
|
||
</button>
|
||
<button
|
||
:class="[
|
||
'flex-1 py-2.5 rounded-xl text-xs font-bold border transition-all uppercase tracking-wider',
|
||
autoScheduleDirection === 'over'
|
||
? 'bg-primary-10 border-primary-30 text-primary'
|
||
: 'bg-bg-primary border-border-secondary text-text-secondary hover:border-border-tertiary'
|
||
]"
|
||
@click="autoScheduleDirection = 'over'"
|
||
>
|
||
{{ locale.directionOver }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-1.5">{{ locale.scheduleAlgorithm }}</label>
|
||
<div class="flex gap-2">
|
||
<button
|
||
:class="[
|
||
'flex-1 py-2.5 rounded-xl text-xs font-bold border transition-all uppercase tracking-wider',
|
||
autoScheduleAlgorithm === 'auto'
|
||
? 'bg-primary-10 border-primary-30 text-primary'
|
||
: 'bg-bg-primary border-border-secondary text-text-secondary hover:border-border-tertiary'
|
||
]"
|
||
@click="autoScheduleAlgorithm = 'auto'"
|
||
>
|
||
{{ locale.algorithmAuto }}
|
||
</button>
|
||
<button
|
||
:class="[
|
||
'flex-1 py-2.5 rounded-xl text-xs font-bold border transition-all uppercase tracking-wider',
|
||
autoScheduleAlgorithm === 'greedy'
|
||
? 'bg-primary-10 border-primary-30 text-primary'
|
||
: 'bg-bg-primary border-border-secondary text-text-secondary hover:border-border-tertiary'
|
||
]"
|
||
@click="autoScheduleAlgorithm = 'greedy'"
|
||
>
|
||
{{ locale.algorithmGreedy }}
|
||
</button>
|
||
<button
|
||
:class="[
|
||
'flex-1 py-2.5 rounded-xl text-xs font-bold border transition-all uppercase tracking-wider',
|
||
autoScheduleAlgorithm === 'exhaustive'
|
||
? 'bg-primary-10 border-primary-30 text-primary'
|
||
: 'bg-bg-primary border-border-secondary text-text-secondary hover:border-border-tertiary'
|
||
]"
|
||
@click="autoScheduleAlgorithm = 'exhaustive'"
|
||
>
|
||
{{ locale.algorithmExhaustive }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="text-[11px] text-text-disabled">
|
||
{{ locale.availableCount(autoScheduleCandidates.length) }}
|
||
</div>
|
||
|
||
<div class="flex gap-2">
|
||
<button
|
||
class="flex-1 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
|
||
@click="closeAutoScheduleDialog"
|
||
>
|
||
{{ locale.cancel }}
|
||
</button>
|
||
<button
|
||
:disabled="!autoScheduleTargetMinutes || autoScheduleTargetMinutes <= 0 || autoScheduleCandidates.length === 0"
|
||
class="flex-1 py-3 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||
@click="runAutoSchedule"
|
||
>
|
||
<Sparkles class="w-3.5 h-3.5" />
|
||
{{ locale.autoScheduleRun }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 结果区 -->
|
||
<div v-else class="space-y-3">
|
||
<div class="flex items-center justify-between px-1">
|
||
<div class="flex items-center gap-2">
|
||
<Sparkles class="w-4 h-4 text-primary" />
|
||
<span class="text-xs font-bold text-text-primary">{{ locale.resultTitle }}</span>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-[11px] text-text-tertiary">
|
||
{{ locale.totalDuration }}
|
||
</span>
|
||
<span class="text-sm font-black text-primary">
|
||
{{ formatDuration(autoScheduleResult.totalDuration) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
:class="[
|
||
'px-3 py-2 rounded-lg text-[11px] font-bold border',
|
||
autoScheduleResult.diff <= 0
|
||
? 'bg-info-10 border-info-20 text-info'
|
||
: 'bg-warning-10 border-warning-20 text-warning'
|
||
]"
|
||
>
|
||
{{ autoScheduleResult.diff <= 0
|
||
? locale.resultUnderTarget(autoScheduleResult.absDiff)
|
||
: locale.resultOverTarget(autoScheduleResult.absDiff)
|
||
}}
|
||
</div>
|
||
|
||
<div class="space-y-2 max-h-[36vh] overflow-y-auto scrollbar-hide">
|
||
<div
|
||
v-for="(song, idx) in autoScheduleResult.songs"
|
||
:key="song.id"
|
||
class="flex items-center gap-3 p-2.5 bg-bg-primary border border-border-secondary-50 rounded-lg"
|
||
>
|
||
<span class="text-[10px] font-bold text-text-disabled w-5 shrink-0">{{ idx + 1 < 10 ? '0' + (idx + 1) : idx + 1 }}</span>
|
||
<div
|
||
class="relative w-10 h-10 rounded-lg overflow-hidden bg-bg-tertiary flex-shrink-0 border border-border-tertiary-50"
|
||
>
|
||
<img
|
||
v-if="song.cover"
|
||
:src="convertToHttps(song.cover)"
|
||
class="w-full h-full object-cover"
|
||
referrerpolicy="no-referrer"
|
||
loading="lazy"
|
||
alt=""
|
||
/>
|
||
<div
|
||
v-else
|
||
class="w-full h-full flex items-center justify-center text-text-disabled"
|
||
>
|
||
<Music2 class="w-5 h-5 opacity-50" />
|
||
</div>
|
||
</div>
|
||
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
||
<span class="text-sm font-bold text-text-primary truncate flex items-center gap-1.5">
|
||
<span>{{ song.title }}</span>
|
||
<span
|
||
v-if="song.isFixed"
|
||
class="inline-flex items-center gap-0.5 px-1 py-0.5 bg-primary-10 text-primary rounded text-[9px] font-bold border border-primary-20 shrink-0"
|
||
>
|
||
<Lock class="w-2.5 h-2.5" />
|
||
固定
|
||
</span>
|
||
</span>
|
||
<span class="text-xs text-text-tertiary truncate flex items-center gap-1.5">
|
||
<span>{{ song.artist }}</span>
|
||
<span
|
||
v-if="song.durationSeconds && !editingDuration[song.id]"
|
||
class="text-text-disabled hover:text-text-secondary hover:bg-bg-quaternary cursor-pointer shrink-0 px-1 rounded transition-colors"
|
||
:title="locale.messages?.editDuration || '点击编辑时长'"
|
||
@click.stop="startEditDuration(song)"
|
||
>{{ formatDuration(song.durationSeconds) }}</span>
|
||
<input
|
||
v-else-if="editingDuration[song.id]"
|
||
ref="editingDurationInput"
|
||
v-model="editingDurationValue"
|
||
type="text"
|
||
inputmode="text"
|
||
pattern="[0-9:]*"
|
||
class="w-20 text-[11px] font-mono text-center bg-bg-primary border border-primary rounded px-1 py-0.5 text-text-primary focus:outline-none focus:border-primary shrink-0"
|
||
:placeholder="locale.messages?.durationInputPlaceholder || '分:秒'"
|
||
@focusout="saveDurationEdit(song)"
|
||
@keydown="handleDurationKeydown($event, song)"
|
||
>
|
||
<span v-if="song.requester" class="text-text-disabled">|</span>
|
||
<span v-if="song.requester" class="text-text-tertiary truncate">{{ song.requester }}</span>
|
||
<span v-if="song.voteCount != null" class="ml-auto flex items-center gap-1 text-[10px] font-bold text-text-tertiary bg-bg-primary-50 px-1.5 py-0.5 rounded-md border border-border-secondary-50 shrink-0">
|
||
<Heart class="w-3 h-3 text-error-50" />
|
||
{{ song.voteCount }}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 方案导航 -->
|
||
<div class="flex items-center gap-2 pt-1">
|
||
<button
|
||
:disabled="currentPlanIndex <= 0 || generatingNewPlan"
|
||
class="flex-1 py-2.5 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1.5"
|
||
@click="goToPreviousPlan"
|
||
>
|
||
<ChevronLeft class="w-3.5 h-3.5" />
|
||
{{ locale.previousPlan }}
|
||
</button>
|
||
<span class="text-[10px] font-bold text-text-tertiary whitespace-nowrap">
|
||
{{ locale.planIndicator(currentPlanIndex + 1, autoSchedulePlans.length) }}
|
||
</span>
|
||
<button
|
||
:disabled="generatingNewPlan"
|
||
class="flex-1 py-2.5 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1.5"
|
||
@click="goToNextPlan"
|
||
>
|
||
<Loader2 v-if="generatingNewPlan" class="w-3.5 h-3.5 animate-spin" />
|
||
<span v-else-if="currentPlanIndex < autoSchedulePlans.length - 1">
|
||
{{ locale.nextPlan }}
|
||
<ChevronRight class="w-3.5 h-3.5" />
|
||
</span>
|
||
<span v-else-if="actualExhaustive" class="flex items-center gap-1.5">
|
||
<Plus class="w-3.5 h-3.5" />
|
||
{{ locale.newPlan }}
|
||
</span>
|
||
<span v-else class="opacity-30">
|
||
{{ locale.nextPlan }}
|
||
<ChevronRight class="w-3.5 h-3.5" />
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="flex gap-2">
|
||
<button
|
||
class="flex-1 py-2.5 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
|
||
@click="resetAutoSchedule"
|
||
>
|
||
{{ locale.tryAgain }}
|
||
</button>
|
||
<button
|
||
class="flex-1 py-2.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-bold rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-colors uppercase tracking-wider"
|
||
@click="confirmAutoSchedule"
|
||
>
|
||
{{ locale.confirmApply }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 上下文菜单 -->
|
||
<div
|
||
v-if="contextMenuOpen"
|
||
class="fixed inset-0 z-50"
|
||
@click="contextMenuOpen = false"
|
||
>
|
||
<div
|
||
class="absolute bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl p-1 min-w-[130px]"
|
||
:style="contextMenuPos"
|
||
@click.stop
|
||
>
|
||
<button
|
||
class="flex items-center gap-2 w-full px-3 py-1.5 rounded-lg text-[11px] font-bold text-text-primary hover:bg-bg-primary transition-colors"
|
||
@click="playSong(contextMenuSong); contextMenuOpen = false"
|
||
>
|
||
<Play class="w-3 h-3" />
|
||
<span>{{ locale.playSong }}</span>
|
||
</button>
|
||
<template v-if="contextMenuSide === 'left'">
|
||
<button
|
||
v-if="activeTab === 'normal' || activeTab === 'all'"
|
||
class="flex items-center gap-2 w-full px-3 py-1.5 rounded-lg text-[11px] font-bold text-text-primary hover:bg-bg-primary transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||
:disabled="poolSongIds.has(contextMenuSong.id)"
|
||
@click="addSingleToPool(contextMenuSong.id); contextMenuOpen = false"
|
||
>
|
||
<FolderPlus class="w-3 h-3" />
|
||
<span>{{ locale.addSingleToPool }}</span>
|
||
</button>
|
||
<button
|
||
v-if="activeTab === 'normal' || activeTab === 'all' || activeTab === 'replay'"
|
||
class="flex items-center gap-2 w-full px-3 py-1.5 rounded-lg text-[11px] font-bold text-text-primary hover:bg-bg-primary transition-colors"
|
||
@click="addSongToSchedule(contextMenuSong); contextMenuOpen = false"
|
||
>
|
||
<PlaySquare class="w-3 h-3" />
|
||
<span>{{ locale.addToSchedule }}</span>
|
||
</button>
|
||
<button
|
||
v-if="activeTab === 'pool'"
|
||
class="flex items-center gap-2 w-full px-3 py-1.5 rounded-lg text-[11px] font-bold text-text-primary hover:bg-bg-primary transition-colors"
|
||
@click="addSongToSchedule(contextMenuSong); contextMenuOpen = false"
|
||
>
|
||
<PlaySquare class="w-3 h-3" />
|
||
<span>{{ locale.addToSchedule }}</span>
|
||
</button>
|
||
<button
|
||
v-if="activeTab === 'pool'"
|
||
class="flex items-center gap-2 w-full px-3 py-1.5 rounded-lg text-[11px] font-bold text-error hover:bg-error-10 transition-colors"
|
||
@click="removeFromPool(contextMenuSong.songId || contextMenuSong.id); contextMenuOpen = false"
|
||
>
|
||
<Trash2 class="w-3 h-3" />
|
||
<span>{{ locale.removeFromPool }}</span>
|
||
</button>
|
||
</template>
|
||
<template v-if="contextMenuSide === 'right'">
|
||
<button
|
||
class="flex items-center gap-2 w-full px-3 py-1.5 rounded-lg text-[11px] font-bold text-error hover:bg-error-10 transition-colors"
|
||
@click="removeSongFromSchedule(contextMenuSong); contextMenuOpen = false"
|
||
>
|
||
<Trash2 class="w-3 h-3" />
|
||
<span>{{ locale.removeFromSchedule }}</span>
|
||
</button>
|
||
<button
|
||
v-if="contextMenuSong.isDraft"
|
||
class="flex items-center gap-2 w-full px-3 py-1.5 rounded-lg text-[11px] font-bold text-success hover:bg-success-10 transition-colors"
|
||
@click="publishSingleDraft(contextMenuSong); contextMenuOpen = false"
|
||
>
|
||
<Send class="w-3 h-3" />
|
||
<span>{{ locale.publishThisDraft }}</span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch, inject } from 'vue'
|
||
import {
|
||
Search,
|
||
Save,
|
||
Send,
|
||
CheckCircle2,
|
||
Download,
|
||
FileBadge,
|
||
PlaySquare,
|
||
Play,
|
||
ChevronDown,
|
||
ListMusic,
|
||
Filter,
|
||
Info,
|
||
Clock,
|
||
User,
|
||
AlertTriangle,
|
||
X as CloseIcon,
|
||
ChevronRight,
|
||
ChevronLeft,
|
||
MoreVertical,
|
||
Calendar as CalendarIcon,
|
||
ArrowLeft,
|
||
ArrowRight,
|
||
Music2,
|
||
Heart,
|
||
Plus,
|
||
Minus,
|
||
CircleDot,
|
||
ExternalLink,
|
||
MessageSquare,
|
||
Trash2,
|
||
Copy,
|
||
RefreshCcw,
|
||
Loader2,
|
||
Sparkles,
|
||
FolderPlus,
|
||
Lock
|
||
} from '@lucide/vue'
|
||
import SongDownloadDialog from './SongDownloadDialog.vue'
|
||
import SubmissionRemarkDialog from './SubmissionRemarkDialog.vue'
|
||
import ConfirmDialog from '../UI/ConfirmDialog.vue'
|
||
import Icon from '~/components/UI/Icon.vue'
|
||
import Pagination from '~/components/UI/Common/Pagination.vue'
|
||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||
import LoadingState from '~/components/UI/Common/LoadingState.vue'
|
||
import { useSongPlayer } from '~/composables/useSongPlayer'
|
||
import { isBilibiliSong } from '~/utils/bilibiliSource'
|
||
import { convertToHttps, getNeteaseCookie } from '~/utils/url'
|
||
import { useLocale } from '~/utils/locale'
|
||
import { useServerErrors } from '~/composables/useLocaleText'
|
||
import { formatDuration, addDaysToString, getDaysBetween } from '~/utils/timeUtils'
|
||
import { autoSchedule, autoScheduleExhaustive, poolCandidateFromItem } from '~/utils/autoSchedule'
|
||
import { getMusicUrlResult, isKnownInvalidQqAudioUrl } from '~/utils/musicUrl'
|
||
|
||
import SchedulePlaylistFilterModal from './SchedulePlaylistFilterModal.vue'
|
||
import { getPlaylistDetail } from '~/utils/neteaseApi'
|
||
|
||
const { admin } = useLocale()
|
||
const { localize: localizeServerError } = useServerErrors()
|
||
const locale = computed(() => {
|
||
const base = admin.value?.scheduleManager || {}
|
||
return useSafeLocale({
|
||
...base,
|
||
messages: {
|
||
confirmAutoScheduleApplied: (count, duration) => `已自动排期 ${count} 首歌曲,合计 ${duration}`,
|
||
...(base.messages || {})
|
||
},
|
||
errors: {
|
||
rejectReplayFailed: (message) => `拒绝申请失败: ${message || '未知错误'}`,
|
||
saveDraftFailed: (message) => `保存草稿失败: ${message || '未知错误'}`,
|
||
publishScheduleFailed: (message) => `发布排期失败: ${message || '未知错误'}`,
|
||
publishDraftFailed: (message) => `发布草稿失败: ${message || '未知错误'}`,
|
||
moveDateFailed: (message) => `迁移失败: ${message || '未知错误'}`,
|
||
...(base.errors || {})
|
||
},
|
||
confirmations: {
|
||
moveDateMessage: (sourceDate, count, targetDate) =>
|
||
`确定将 ${sourceDate} 的所有 ${count} 首歌曲迁移到 ${targetDate} 吗?歌曲顺序与内容将保持不变。`,
|
||
publishDraftMessage: (title) =>
|
||
`确定要发布草稿《${title}》吗?发布后将立即公示并发送通知。`,
|
||
...(base.confirmations || {})
|
||
},
|
||
andMoreApplicants: base.andMoreApplicants || ((count) => ` 等${count}人`),
|
||
currentDate: base.currentDate || ((date) => `当前日期:${date}`),
|
||
replayDetailTitle: base.replayDetailTitle || ((title) => `${title} - 重播申请详情`),
|
||
timeAgo: {
|
||
...(base.timeAgo || {}),
|
||
never: base.timeAgo?.never || base.unknown || '从未',
|
||
justNow: base.timeAgo?.justNow || '刚刚',
|
||
minutes: (value) => formatLocaleValue(base.timeAgo?.minutes, value) || `${value} 分钟前`,
|
||
hours: (value) => formatLocaleValue(base.timeAgo?.hours, value) || `${value} 小时前`,
|
||
days: (value) => formatLocaleValue(base.timeAgo?.days, value) || `${value} 天前`
|
||
}
|
||
})
|
||
})
|
||
const { t: callLocale, nested: getNestedMessage } = useLocaleText(locale)
|
||
|
||
// 通知文案:优先 i18n 分区取值(section.key),异常时回退硬编码(防止异步回调作用域问题导致报错)
|
||
const safeMessage = (section, key, fallback) => {
|
||
try {
|
||
const text = getNestedMessage(section, key)
|
||
return text || fallback
|
||
} catch {
|
||
return fallback
|
||
}
|
||
}
|
||
|
||
const getTodayDateValue = () => getBeijingTimeISOString().slice(0, 10)
|
||
|
||
// 日期选择器只关心日历日期,避免 UTC 转换让北京时间凌晨落到前一天
|
||
const parseDateValue = (dateValue) => {
|
||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateValue)
|
||
if (!match) return null
|
||
|
||
const year = Number(match[1])
|
||
const month = Number(match[2])
|
||
const day = Number(match[3])
|
||
const date = new Date(Date.UTC(year, month - 1, day))
|
||
|
||
if (
|
||
!Number.isFinite(year) ||
|
||
!Number.isFinite(month) ||
|
||
!Number.isFinite(day) ||
|
||
date.getUTCFullYear() !== year ||
|
||
date.getUTCMonth() !== month - 1 ||
|
||
date.getUTCDate() !== day
|
||
) {
|
||
return null
|
||
}
|
||
|
||
return { year, month, day }
|
||
}
|
||
|
||
const formatUtcDateValue = (date) => {
|
||
const year = date.getUTCFullYear()
|
||
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getUTCDate()).padStart(2, '0')
|
||
return `${year}-${month}-${day}`
|
||
}
|
||
|
||
const addDaysToDateValue = (dateValue, days) => {
|
||
const parsed = parseDateValue(dateValue)
|
||
if (!parsed) return dateValue
|
||
|
||
const date = new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day + days))
|
||
return formatUtcDateValue(date)
|
||
}
|
||
|
||
const getScheduleDateValue = (playDate) => {
|
||
if (!playDate) return ''
|
||
|
||
if (typeof playDate === 'string') {
|
||
const dateValue = playDate.slice(0, 10)
|
||
if (parseDateValue(dateValue)) return dateValue
|
||
}
|
||
|
||
const date = new Date(playDate)
|
||
if (Number.isNaN(date.getTime())) return ''
|
||
return formatUtcDateValue(date)
|
||
}
|
||
|
||
// 响应式数据
|
||
const selectedDate = useState('adminSelectedDate', getTodayDateValue)
|
||
const loading = ref(false)
|
||
const songSortOption = ref('votes-desc')
|
||
const hasChanges = ref(false)
|
||
const searchQuery = ref('')
|
||
const selectedGrade = ref('')
|
||
const activeTab = ref('normal')
|
||
const mobileTab = ref('pending')
|
||
const mobileFiltersOpen = ref(false)
|
||
const scheduleTabs = computed(() => [
|
||
{ id: 'normal', label: locale.value?.tabs?.normal || '普通投稿' },
|
||
{ id: 'replay', label: locale.value?.tabs?.replay || '重播申请' },
|
||
{ id: 'all', label: locale.value?.tabs?.all || '所有' },
|
||
{ id: 'pool', label: locale.value?.tabs?.pool || locale.value?.poolList || '备选池' }
|
||
])
|
||
|
||
// 歌单过滤状态
|
||
const showPlaylistFilterModal = ref(false)
|
||
const isPlaylistFilterActive = ref(false)
|
||
const playlistFilterTrackIds = ref(new Set())
|
||
const playlistNamesMap = ref({})
|
||
|
||
const handlePlaylistFilterApply = async (playlistIds, playlistTracks = {}, playlistNames = {}) => {
|
||
if (!playlistIds || playlistIds.length === 0) {
|
||
isPlaylistFilterActive.value = false
|
||
playlistFilterTrackIds.value = new Set()
|
||
playlistNamesMap.value = {}
|
||
return
|
||
}
|
||
|
||
isPlaylistFilterActive.value = true
|
||
const newTrackIds = new Set()
|
||
const newNamesMap = {}
|
||
const cookie = getNeteaseCookie()
|
||
|
||
const fetchPromises = playlistIds.map(async (id) => {
|
||
const playlistName = playlistNames[id] || callLocale('playlistName', `Playlist ${id}`, id)
|
||
let trackIds = []
|
||
|
||
// 优先使用从组件中传来的已经缓存的 trackIds
|
||
if (playlistTracks && playlistTracks[id]) {
|
||
trackIds = playlistTracks[id]
|
||
} else {
|
||
// 缓存中没有则重新请求
|
||
try {
|
||
const res = await getPlaylistDetail(id, cookie)
|
||
if (
|
||
res &&
|
||
res.code === 200 &&
|
||
res.body &&
|
||
res.body.playlist &&
|
||
res.body.playlist.trackIds
|
||
) {
|
||
trackIds = res.body.playlist.trackIds.map((t) => t.id.toString())
|
||
}
|
||
} catch (err) {
|
||
console.error(`获取歌单 ${id} 失败:`, err)
|
||
}
|
||
}
|
||
|
||
// 存入集合并建立映射关系
|
||
trackIds.forEach((t) => {
|
||
newTrackIds.add(t)
|
||
if (!newNamesMap[t]) {
|
||
newNamesMap[t] = new Set()
|
||
}
|
||
newNamesMap[t].add(playlistName)
|
||
})
|
||
})
|
||
|
||
await Promise.all(fetchPromises)
|
||
|
||
playlistFilterTrackIds.value = newTrackIds
|
||
playlistNamesMap.value = {}
|
||
Object.keys(newNamesMap).forEach((key) => {
|
||
playlistNamesMap.value[key] = Array.from(newNamesMap[key])
|
||
})
|
||
}
|
||
|
||
// 音频播放器
|
||
const { playSong: playSongWithQueue } = useSongPlayer()
|
||
|
||
// 播放歌曲:以歌曲所在列表(播放顺序/待排歌曲)作为播放队列,支持上下切歌与循环模式
|
||
const playSong = (songOrSchedule) => {
|
||
// 兼容右键菜单传入的排期项(含 .song 字段)
|
||
const song = songOrSchedule && songOrSchedule.song ? songOrSchedule.song : songOrSchedule
|
||
if (!song) return
|
||
// 播放顺序列表中的歌曲,以播放顺序为队列
|
||
if (
|
||
localScheduledSongs.value.some((s) => s.song && String(s.song.id) === String(song.id))
|
||
) {
|
||
playSongWithQueue(song, localScheduledSongs.value.map((s) => s.song))
|
||
return
|
||
}
|
||
// 待排歌曲列表中的歌曲,以当前待排列表为队列
|
||
if (filteredUnscheduledSongs.value.some((s) => String(s.id) === String(song.id))) {
|
||
playSongWithQueue(song, filteredUnscheduledSongs.value)
|
||
return
|
||
}
|
||
playSongWithQueue(song)
|
||
}
|
||
|
||
// 确认对话框相关
|
||
const showConfirmDialog = ref(false)
|
||
const confirmDialogTitle = ref('')
|
||
const confirmDialogMessage = ref('')
|
||
const confirmDialogType = ref('warning')
|
||
const confirmDialogConfirmText = ref('')
|
||
const confirmAction = ref(null)
|
||
|
||
// 下载相关
|
||
const showDownloadDialog = ref(false)
|
||
const openDownloadDialog = () => {
|
||
showDownloadDialog.value = true
|
||
}
|
||
|
||
// 重播申请弹窗相关
|
||
const showReplayModal = ref(false)
|
||
const replayModalTitle = ref('')
|
||
const replayModalRequests = ref([])
|
||
const replayModalSongId = ref(null)
|
||
// 刷新时长状态(每首歌独立追踪)
|
||
const refreshingDuration = ref({})
|
||
// 时长刷新结果状态(用于颜色标记:'success' | 'error' | null)
|
||
const durationRefreshStatus = ref({})
|
||
// 批量刷新时长状态
|
||
const refreshingAllDurations = ref({ running: false, progress: '', done: 0, total: 0 })
|
||
const refreshingAutoCandidates = ref({ running: false, progress: '', success: 0, fail: 0 })
|
||
// 行内编辑歌曲时长状态
|
||
const editingDuration = ref({})
|
||
const editingDurationValue = ref('')
|
||
const editingDurationInput = ref(null)
|
||
const durationSaveInFlight = new Set()
|
||
|
||
const formatDurationInput = (seconds) => {
|
||
const total = Number(seconds)
|
||
if (!Number.isInteger(total) || total < 0) return ''
|
||
const hours = Math.floor(total / 3600)
|
||
const minutes = Math.floor((total % 3600) / 60)
|
||
const remainingSeconds = total % 60
|
||
const paddedSeconds = String(remainingSeconds).padStart(2, '0')
|
||
return hours > 0
|
||
? `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`
|
||
: `${minutes}:${paddedSeconds}`
|
||
}
|
||
|
||
const parseDurationInput = (value) => {
|
||
const parts = value.split(':')
|
||
if (parts.length !== 2 && parts.length !== 3) return null
|
||
if (parts.some((part) => !/^\d+$/.test(part))) return null
|
||
|
||
const values = parts.map(Number)
|
||
const hours = parts.length === 3 ? values[0] : 0
|
||
const minutes = parts.length === 3 ? values[1] : values[0]
|
||
const seconds = parts.length === 3 ? values[2] : values[1]
|
||
if (minutes > 59 || seconds > 59) return null
|
||
|
||
const total = hours * 3600 + minutes * 60 + seconds
|
||
return total <= 7200 ? total : null
|
||
}
|
||
|
||
// 开始编辑歌曲时长
|
||
const startEditDuration = (song) => {
|
||
if (editingDuration.value[song.id]) return
|
||
editingDuration.value[song.id] = true
|
||
editingDurationValue.value = song.durationSeconds == null
|
||
? ''
|
||
: formatDurationInput(song.durationSeconds)
|
||
nextTick(() => {
|
||
const input = Array.isArray(editingDurationInput.value)
|
||
? editingDurationInput.value[0]
|
||
: editingDurationInput.value
|
||
if (input) {
|
||
input.focus()
|
||
input.select()
|
||
}
|
||
})
|
||
}
|
||
|
||
// 保存编辑的歌曲时长
|
||
const saveDurationEdit = async (song) => {
|
||
if (!editingDuration.value[song.id] || durationSaveInFlight.has(song.id)) return
|
||
durationSaveInFlight.add(song.id)
|
||
|
||
try {
|
||
const raw = editingDurationValue.value.trim()
|
||
if (raw === '') {
|
||
if (song.durationSeconds == null) {
|
||
cancelEditDuration(song.id)
|
||
return
|
||
}
|
||
// 清空时长
|
||
const updated = await updateSongDuration(song.id, null)
|
||
if (!updated) return
|
||
delete editingDuration.value[song.id]
|
||
editingDurationValue.value = ''
|
||
return
|
||
}
|
||
|
||
const seconds = parseDurationInput(raw)
|
||
if (seconds == null) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages?.durationInvalid || '时长格式无效,请输入 分:秒 或 时:分:秒', 'error')
|
||
}
|
||
return
|
||
}
|
||
|
||
if (seconds === Number(song.durationSeconds)) {
|
||
cancelEditDuration(song.id)
|
||
return
|
||
}
|
||
|
||
const updated = await updateSongDuration(song.id, seconds)
|
||
if (!updated) return
|
||
delete editingDuration.value[song.id]
|
||
editingDurationValue.value = ''
|
||
} finally {
|
||
durationSaveInFlight.delete(song.id)
|
||
}
|
||
}
|
||
|
||
// 通用:更新歌曲时长并同步所有列表
|
||
const updateSongDuration = async (songId, durationSeconds) => {
|
||
try {
|
||
await $fetch('/api/admin/songs/duration', {
|
||
method: 'POST',
|
||
body: { songId, durationSeconds },
|
||
...(auth ? auth.getAuthConfig?.() : {})
|
||
})
|
||
|
||
const normalized = durationSeconds ?? null
|
||
// 更新待排歌曲列表
|
||
const pendingIdx = songs.value.findIndex((s) => s.id === songId)
|
||
if (pendingIdx !== -1) {
|
||
songs.value[pendingIdx].durationSeconds = normalized
|
||
}
|
||
// 更新已排歌曲列表
|
||
for (const schedule of localScheduledSongs.value) {
|
||
if (schedule.song && schedule.song.id === songId) {
|
||
schedule.song.durationSeconds = normalized
|
||
break
|
||
}
|
||
}
|
||
// 更新备选池
|
||
for (const p of songPool.value) {
|
||
if (p.songId === songId) { p.durationSeconds = normalized; break }
|
||
}
|
||
// 更新重播申请
|
||
for (const r of replayRequests.value) {
|
||
if (r.id === songId) { r.durationSeconds = normalized; break }
|
||
}
|
||
// 更新自动排期候选
|
||
if (autoScheduleCandidates.value) {
|
||
const cand = autoScheduleCandidates.value.find((c) => c.id === songId)
|
||
if (cand) cand.durationSeconds = normalized
|
||
}
|
||
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages?.durationUpdated || '时长已更新', 'success')
|
||
}
|
||
return true
|
||
} catch (err) {
|
||
console.error('更新时长失败:', err)
|
||
if (window.$showNotification) {
|
||
window.$showNotification(localizeServerError(err), 'error')
|
||
}
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 取消编辑
|
||
const cancelEditDuration = (songId) => {
|
||
delete editingDuration.value[songId]
|
||
editingDurationValue.value = ''
|
||
}
|
||
|
||
// 键盘事件处理
|
||
const handleDurationKeydown = (event, song) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault()
|
||
saveDurationEdit(song)
|
||
} else if (event.key === 'Escape') {
|
||
event.preventDefault()
|
||
cancelEditDuration(song.id)
|
||
}
|
||
}
|
||
// 自动排期状态
|
||
const showAutoScheduleDialog = ref(false)
|
||
const contextMenuOpen = ref(false)
|
||
const contextMenuSide = ref('left')
|
||
const contextMenuSong = ref(null)
|
||
const contextMenuPos = ref({ top: '50%', left: '50%' })
|
||
const openContextMenu = (e, side, song) => {
|
||
e.stopPropagation()
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
const menuW = 150
|
||
const menuH = 120
|
||
contextMenuSide.value = side
|
||
contextMenuSong.value = song
|
||
const vW = window.innerWidth
|
||
let left = rect.right + 8
|
||
if (left + menuW > vW) {
|
||
left = rect.left - menuW - 8
|
||
}
|
||
if (left < 8) left = vW - menuW - 8
|
||
const top = rect.bottom + 8
|
||
const adjustedTop = top + menuH > window.innerHeight ? top - menuH : top
|
||
contextMenuPos.value = {
|
||
top: `${adjustedTop}px`,
|
||
left: `${left}px`
|
||
}
|
||
contextMenuOpen.value = true
|
||
}
|
||
const closeContextMenu = () => { contextMenuOpen.value = false }
|
||
const autoScheduleTargetMinutes = ref(null)
|
||
const autoScheduleTargetSongCount = ref(null)
|
||
const autoScheduleTargetRequesterCount = ref(null)
|
||
const autoScheduleDirection = ref('under')
|
||
const autoScheduleFixExisting = ref(false)
|
||
const autoScheduleResult = ref({ songs: [], totalDuration: 0, diff: 0, absDiff: 0 })
|
||
const autoSchedulePlans = ref([])
|
||
const currentPlanIndex = ref(0)
|
||
const autoScheduleAlgorithm = ref('greedy')
|
||
|
||
// 自动排期设置记忆(localStorage 持久化,下次打开弹窗沿用上次配置)
|
||
const AUTO_SCHEDULE_SETTINGS_KEY = 'voicehub_auto_schedule_settings'
|
||
const AUTO_SCHEDULE_DIRECTIONS = ['under', 'middle', 'over']
|
||
const AUTO_SCHEDULE_ALGORITHMS = ['auto', 'greedy', 'exhaustive']
|
||
const loadAutoScheduleSettings = () => {
|
||
if (!import.meta.client) return
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(AUTO_SCHEDULE_SETTINGS_KEY) || 'null')
|
||
if (!saved || typeof saved !== 'object') return
|
||
if (Number.isFinite(saved.targetMinutes) && saved.targetMinutes > 0) autoScheduleTargetMinutes.value = saved.targetMinutes
|
||
if (Number.isFinite(saved.targetSongCount) && saved.targetSongCount > 0) autoScheduleTargetSongCount.value = Math.floor(saved.targetSongCount)
|
||
if (Number.isFinite(saved.targetRequesterCount) && saved.targetRequesterCount > 0) autoScheduleTargetRequesterCount.value = Math.floor(saved.targetRequesterCount)
|
||
if (AUTO_SCHEDULE_DIRECTIONS.includes(saved.direction)) autoScheduleDirection.value = saved.direction
|
||
if (AUTO_SCHEDULE_ALGORITHMS.includes(saved.algorithm)) autoScheduleAlgorithm.value = saved.algorithm
|
||
autoScheduleFixExisting.value = !!saved.fixExisting
|
||
} catch {
|
||
// 非法数据忽略,回退默认
|
||
}
|
||
}
|
||
const saveAutoScheduleSettings = () => {
|
||
if (!import.meta.client) return
|
||
try {
|
||
localStorage.setItem(AUTO_SCHEDULE_SETTINGS_KEY, JSON.stringify({
|
||
targetMinutes: autoScheduleTargetMinutes.value,
|
||
targetSongCount: autoScheduleTargetSongCount.value,
|
||
targetRequesterCount: autoScheduleTargetRequesterCount.value,
|
||
direction: autoScheduleDirection.value,
|
||
algorithm: autoScheduleAlgorithm.value,
|
||
fixExisting: autoScheduleFixExisting.value
|
||
}))
|
||
} catch {
|
||
// 存储失败忽略
|
||
}
|
||
}
|
||
watch(
|
||
[autoScheduleTargetMinutes, autoScheduleTargetSongCount, autoScheduleTargetRequesterCount, autoScheduleDirection, autoScheduleAlgorithm, autoScheduleFixExisting],
|
||
saveAutoScheduleSettings
|
||
)
|
||
loadAutoScheduleSettings()
|
||
|
||
const autoScheduleScheduledSeconds = computed(() => {
|
||
if (!autoScheduleFixExisting.value) return 0
|
||
return localScheduledSongs.value.reduce((sum, s) => {
|
||
const dur = s.song && typeof s.song.durationSeconds === 'number' ? s.song.durationSeconds : 0
|
||
return sum + dur
|
||
}, 0)
|
||
})
|
||
|
||
const actualExhaustive = computed(() => {
|
||
return autoScheduleAlgorithm.value === 'exhaustive' ||
|
||
(autoScheduleAlgorithm.value === 'auto' && autoScheduleCandidates.value.length < 20)
|
||
})
|
||
const generatingNewPlan = ref(false)
|
||
// 备选池
|
||
const songPool = ref([])
|
||
const poolLoading = ref(false)
|
||
// 备选池已包含的歌曲 ID 集合,用于禁用重复加入按钮
|
||
const poolSongIds = computed(() => new Set(songPool.value.map((p) => p.songId)))
|
||
// 备选池统一过滤:已播放或已在任意日期排期(含草稿)/已加入当前播放顺序的歌曲不再是候选
|
||
const filteredPoolSongs = computed(() => {
|
||
const scheduledIds = new Set(localScheduledSongs.value.map((s) => s.song && s.song.id).filter(Boolean))
|
||
return songPool.value.filter((item) => {
|
||
if (item.played) return false
|
||
if (scheduledSongIds.value.has(item.songId) || scheduledIds.has(item.songId)) return false
|
||
return true
|
||
})
|
||
})
|
||
const showMoveDateDialog = ref(false)
|
||
const moveTargetDate = ref('')
|
||
const showCopyDateDialog = ref(false)
|
||
const copyMode = ref('single')
|
||
const copyFromStart = ref('')
|
||
const copyFromEnd = ref('')
|
||
const copyToStart = ref('')
|
||
const copyToEnd = ref('')
|
||
const copySingleTargetDate = ref('')
|
||
const submissionRemarkDialog = ref({
|
||
show: false,
|
||
songId: null,
|
||
replayRequestId: null,
|
||
title: '',
|
||
artist: '',
|
||
songTitle: '',
|
||
content: '',
|
||
isPublic: true,
|
||
isUpdatingPublic: false,
|
||
status: null
|
||
})
|
||
|
||
const openReplayModal = (song) => {
|
||
replayModalTitle.value = song.title
|
||
replayModalRequests.value = song.requestDetails || []
|
||
replayModalSongId.value = song.id
|
||
showReplayModal.value = true
|
||
}
|
||
|
||
const closeReplayModal = () => {
|
||
showReplayModal.value = false
|
||
replayModalTitle.value = ''
|
||
replayModalRequests.value = []
|
||
replayModalSongId.value = null
|
||
}
|
||
|
||
const openSubmissionRemark = (song, scheduleReplayRequestId = null) => {
|
||
if (!song?.submissionNote) return
|
||
submissionRemarkDialog.value = {
|
||
show: true,
|
||
songId: song.id,
|
||
// 排期卡片的 replayRequestId 在排期顶层而非 song 子对象,优先使用显式传入的绑定
|
||
replayRequestId: scheduleReplayRequestId || song.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 updateSubmissionNotePublicStatus = async (status) => {
|
||
const dialogData = submissionRemarkDialog.value
|
||
if (!dialogData.songId || dialogData.isUpdatingPublic) return
|
||
|
||
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 applyLocal = (song) => {
|
||
song.submissionNotePublicStatus = status
|
||
if (status === 'approved') song.submissionNotePublic = true
|
||
}
|
||
if (songsService && songsService.songs && songsService.songs.value) {
|
||
const songIndex = songsService.songs.value.findIndex((s) => s.id === dialogData.songId)
|
||
if (songIndex !== -1) applyLocal(songsService.songs.value[songIndex])
|
||
}
|
||
for (const scheduleList of [localScheduledSongs.value, publicSchedules.value]) {
|
||
const scheduleIndex = scheduleList.findIndex(
|
||
(s) => s.song && s.song.id === dialogData.songId
|
||
)
|
||
if (scheduleIndex !== -1) applyLocal(scheduleList[scheduleIndex].song)
|
||
}
|
||
const replayIndex = replayRequests.value.findIndex((s) => s.id === dialogData.songId)
|
||
if (replayIndex !== -1) applyLocal(replayRequests.value[replayIndex])
|
||
|
||
dialogData.status = status
|
||
if (status === 'approved') dialogData.isPublic = true
|
||
|
||
if (window.$showNotification) {
|
||
try {
|
||
window.$showNotification(
|
||
safeMessage('messages', status === 'rejected' ? 'remarkRejected' : 'remarkApproved', '备注留言审核状态已更新'),
|
||
'success'
|
||
)
|
||
} catch (notifyErr) {
|
||
// 静默失败,不影响主流程
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('更新备注审核状态失败:', error)
|
||
if (window.$showNotification) {
|
||
try {
|
||
window.$showNotification(safeMessage('errors', 'remarkUpdateFailed', '备注留言审核状态更新失败'), 'error')
|
||
} catch (notifyErr) {
|
||
// 静默失败,不影响主流程
|
||
}
|
||
}
|
||
} finally {
|
||
dialogData.isUpdatingPublic = false
|
||
}
|
||
}
|
||
|
||
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 applyNotePublic = (song) => {
|
||
song.submissionNotePublic = isPublic
|
||
song.submissionNotePublicStatus = isPublic ? 'approved' : null
|
||
}
|
||
if (songsService && songsService.songs && songsService.songs.value) {
|
||
const songIndex = songsService.songs.value.findIndex((s) => s.id === dialogData.songId)
|
||
if (songIndex !== -1) {
|
||
applyNotePublic(songsService.songs.value[songIndex])
|
||
}
|
||
}
|
||
|
||
// 更新排期列表中的重播申请备注可见性
|
||
for (const scheduleList of [localScheduledSongs.value, publicSchedules.value]) {
|
||
const scheduleIndex = scheduleList.findIndex(
|
||
(s) => s.song && s.song.id === dialogData.songId
|
||
)
|
||
if (scheduleIndex !== -1) {
|
||
applyNotePublic(scheduleList[scheduleIndex].song)
|
||
}
|
||
}
|
||
|
||
// 更新重播请求列表中的备注可见性
|
||
const replayIndex = replayRequests.value.findIndex((s) => s.id === dialogData.songId)
|
||
if (replayIndex !== -1) {
|
||
applyNotePublic(replayRequests.value[replayIndex])
|
||
}
|
||
dialogData.status = isPublic ? 'approved' : null
|
||
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages.remarkVisibilityUpdated, 'success')
|
||
}
|
||
} catch (error) {
|
||
console.error('更新备注可见性失败:', error)
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.errors.remarkVisibilityUpdateFailed, 'error')
|
||
}
|
||
dialogData.isPublic = !isPublic
|
||
} finally {
|
||
dialogData.isUpdatingPublic = false
|
||
}
|
||
}
|
||
|
||
// 拖拽状态
|
||
const isDraggableOver = ref(false)
|
||
const isSequenceOver = ref(false)
|
||
const dragOverIndex = ref(-1)
|
||
const draggedSchedule = ref(null)
|
||
|
||
// 触摸拖拽状态
|
||
const touchDragData = ref(null)
|
||
const touchStartPos = ref({ x: 0, y: 0 })
|
||
const touchCurrentPos = ref({ x: 0, y: 0 })
|
||
const isDragging = ref(false)
|
||
const isLongPressing = ref(false)
|
||
const dragElement = ref(null)
|
||
const longPressTimer = ref(null)
|
||
const touchStartTime = ref(0)
|
||
|
||
// 触控拖拽配置
|
||
const TOUCH_CONFIG = {
|
||
LONG_PRESS_DURATION: 500, // 长按识别时间(毫秒)
|
||
DRAG_THRESHOLD: 15, // 拖拽触发阈值(像素)
|
||
VIBRATION_DURATION: 50, // 震动时长(毫秒)
|
||
SCROLL_THRESHOLD: 10 // 滚动阈值(像素)
|
||
}
|
||
|
||
// DOM引用
|
||
const dateSelector = ref(null)
|
||
const sequenceList = ref(null)
|
||
|
||
// 滚动状态
|
||
const isFirstDateVisible = ref(true)
|
||
const isLastDateVisible = ref(true)
|
||
|
||
// 数据
|
||
const songs = ref([])
|
||
const publicSchedules = ref([])
|
||
const localScheduledSongs = ref([])
|
||
const replayRequests = ref([])
|
||
const scheduledSongIds = ref(new Set())
|
||
|
||
// 已排期歌曲的总时长(秒)
|
||
const scheduledTotalDuration = computed(() => {
|
||
return localScheduledSongs.value.reduce((sum, s) => {
|
||
const dur = s.song && typeof s.song.durationSeconds === 'number' ? s.song.durationSeconds : 0
|
||
return sum + dur
|
||
}, 0)
|
||
})
|
||
|
||
// 计算是否有未发布的草稿
|
||
const hasUnpublishedDrafts = computed(() => {
|
||
return localScheduledSongs.value.some((schedule) => schedule.isDraft)
|
||
})
|
||
|
||
// 计算是否有变化或有未发布的草稿
|
||
const canPublish = computed(() => {
|
||
return (hasChanges.value && localScheduledSongs.value.length > 0) || hasUnpublishedDrafts.value
|
||
})
|
||
|
||
// 草稿相关数据
|
||
const drafts = ref([])
|
||
const isDraftMode = ref(false)
|
||
|
||
// 播出时段相关
|
||
const playTimes = ref([])
|
||
const playTimeEnabled = ref(false)
|
||
const selectedPlayTime = ref('')
|
||
const selectedFilterPlayTime = ref('all')
|
||
|
||
// 待排歌曲的播出时段筛选选项
|
||
const filterPlayTimeOptions = computed(() => {
|
||
const options = [
|
||
{ label: locale.value.allPlayTimes, value: 'all' },
|
||
{ label: locale.value.unspecifiedPlayTime, value: 'none' }
|
||
]
|
||
if (playTimes.value) {
|
||
playTimes.value.forEach((pt) => {
|
||
let label = pt.name
|
||
if (pt.startTime || pt.endTime) {
|
||
label += ` (${formatPlayTimeRange(pt)})`
|
||
}
|
||
options.push({ label, value: pt.id })
|
||
})
|
||
}
|
||
return options
|
||
})
|
||
|
||
// 播出时段选项
|
||
const playTimeOptions = computed(() => {
|
||
const options = [{ label: locale.value.noPlayTimeAllDay, value: '' }]
|
||
if (playTimes.value) {
|
||
playTimes.value.forEach((pt) => {
|
||
let label = pt.name
|
||
if (pt.startTime || pt.endTime) {
|
||
label += ` (${formatPlayTimeRange(pt)})`
|
||
}
|
||
options.push({ label, value: pt.id })
|
||
})
|
||
}
|
||
return options
|
||
})
|
||
|
||
// 排序选项
|
||
const sortOptions = computed(() => [
|
||
{ label: locale.value?.sortOptions?.newest || 'Newest', value: 'time-desc' },
|
||
{ label: locale.value?.sortOptions?.oldest || 'Oldest', value: 'time-asc' },
|
||
{ label: locale.value?.sortOptions?.hotDesc || 'Most votes', value: 'votes-desc' },
|
||
{ label: locale.value?.sortOptions?.hotAsc || 'Fewest votes', value: 'votes-asc' }
|
||
])
|
||
|
||
// 学期相关
|
||
const availableSemesters = ref([])
|
||
const selectedSemester = ref('')
|
||
|
||
// 日期范围(用于无限滚动)
|
||
const dateRange = ref({ start: -30, end: 30 })
|
||
|
||
// 手动日期选择
|
||
const showManualDatePicker = ref(false)
|
||
const manualSelectedDate = ref('')
|
||
|
||
// 分页相关
|
||
const pageStates = reactive({
|
||
normal: 1,
|
||
replay: 1,
|
||
all: 1,
|
||
pool: 1
|
||
})
|
||
const currentPage = computed({
|
||
get: () => {
|
||
const page = pageStates[activeTab.value] || 1
|
||
// 页码不超过总页数,避免最后一页歌曲被移出后显示空页
|
||
return Math.min(page, Math.max(1, totalPages.value))
|
||
},
|
||
set: (val) => {
|
||
if (pageStates[activeTab.value] !== undefined) {
|
||
pageStates[activeTab.value] = val
|
||
}
|
||
}
|
||
})
|
||
const pageSize = ref(10)
|
||
|
||
// 服务
|
||
let songsService = null
|
||
let adminService = null
|
||
let auth = null
|
||
let semesterService = null
|
||
|
||
// 生成日期列表(无限滚动模式)
|
||
const availableDates = computed(() => {
|
||
const dates = []
|
||
const todayValue = getTodayDateValue()
|
||
|
||
// 根据当前范围生成日期
|
||
for (let i = dateRange.value.start; i <= dateRange.value.end; i++) {
|
||
const dateStr = addDaysToDateValue(todayValue, i)
|
||
const parsedDate = parseDateValue(dateStr)
|
||
if (!parsedDate) continue
|
||
|
||
const isToday = i === 0
|
||
const weekdays = locale.value?.weekdays || ['日', '一', '二', '三', '四', '五', '六']
|
||
const weekday = weekdays[new Date(Date.UTC(parsedDate.year, parsedDate.month - 1, parsedDate.day)).getUTCDay()]
|
||
|
||
dates.push({
|
||
value: dateStr,
|
||
day: String(parsedDate.day).padStart(2, '0'),
|
||
month: String(parsedDate.month).padStart(2, '0'),
|
||
weekday,
|
||
isToday
|
||
})
|
||
}
|
||
|
||
return dates
|
||
})
|
||
|
||
// 获取所有可选年级
|
||
const availableGrades = computed(() => {
|
||
if (!songs.value) return [{ label: locale.value.allGrades, value: '' }]
|
||
|
||
const grades = new Set()
|
||
songs.value.forEach((song) => {
|
||
if (song.requesterGrade) {
|
||
grades.add(song.requesterGrade)
|
||
}
|
||
})
|
||
|
||
// 对年级进行简单排序
|
||
const sortedGrades = Array.from(grades).sort()
|
||
return [
|
||
{ label: locale.value.allGrades, value: '' },
|
||
...sortedGrades.map((grade) => ({ label: grade, value: grade }))
|
||
]
|
||
})
|
||
|
||
// 过滤未排期歌曲(所有)
|
||
const allUnscheduledSongs = computed(() => {
|
||
// 备选池模式
|
||
if (activeTab.value === 'pool') {
|
||
let poolSongs = filteredPoolSongs.value
|
||
if (searchQuery.value) {
|
||
const query = searchQuery.value.toLowerCase()
|
||
poolSongs = poolSongs.filter((song) => {
|
||
const title = (song.title || '').toLowerCase()
|
||
const artist = (song.artist || '').toLowerCase()
|
||
const requester = (song.requester || '').toLowerCase()
|
||
return title.includes(query) || artist.includes(query) || requester.includes(query)
|
||
})
|
||
}
|
||
// 补充默认字段,保持与歌曲卡片渲染一致
|
||
return poolSongs.map((item) => ({
|
||
...item,
|
||
id: item.songId,
|
||
voteCount: item.voteCount || 0,
|
||
cardCodeId: item.cardCodeId || null,
|
||
usedCardCode: item.usedCardCode || false,
|
||
hasSubmissionNote: item.hasSubmissionNote || false,
|
||
submissionNote: item.submissionNote || null,
|
||
preferredPlayTimeId: item.preferredPlayTimeId || null,
|
||
musicId: item.musicId || null,
|
||
musicPlatform: item.musicPlatform || null,
|
||
requesterGrade: item.requesterGrade || null,
|
||
requesterClass: item.requesterClass || null
|
||
}))
|
||
}
|
||
|
||
const sourceData = activeTab.value === 'replay' ? replayRequests.value : songs.value
|
||
if (!sourceData) return []
|
||
|
||
let unscheduledSongs = sourceData.filter((song) => {
|
||
// 检查是否已在当前显示的排期列表中(当前日期、当前时段)
|
||
const isScheduledInCurrentView = localScheduledSongs.value.some(
|
||
(s) => (s.song && s.song.id === song.id) || s.songId === song.id
|
||
)
|
||
|
||
if (isScheduledInCurrentView) return false
|
||
|
||
if (activeTab.value === 'replay' || activeTab.value === 'all') {
|
||
// 重播申请和所有歌曲模式不需要检查 played 状态,只要当前视图没排上就行
|
||
return true
|
||
} else {
|
||
// 普通投稿需未播放,且未在任何日期的排期中
|
||
const isAlreadyScheduled = song.scheduled || scheduledSongIds.value.has(song.id)
|
||
return !song.played && !isAlreadyScheduled
|
||
}
|
||
})
|
||
|
||
// 搜索过滤
|
||
if (searchQuery.value) {
|
||
const query = searchQuery.value.toLowerCase()
|
||
unscheduledSongs = unscheduledSongs.filter((song) => {
|
||
const title = (song.title || '').toLowerCase()
|
||
const artist = (song.artist || '').toLowerCase()
|
||
const requester = (song.requester || '').toLowerCase()
|
||
|
||
return title.includes(query) || artist.includes(query) || requester.includes(query)
|
||
})
|
||
}
|
||
|
||
// 年级过滤 (针对普通投稿和所有歌曲)
|
||
if (
|
||
(activeTab.value === 'normal' || activeTab.value === 'all') &&
|
||
selectedGrade.value
|
||
) {
|
||
unscheduledSongs = unscheduledSongs.filter(
|
||
(song) => song.requesterGrade === selectedGrade.value
|
||
)
|
||
}
|
||
|
||
// 播出时段过滤
|
||
if (selectedFilterPlayTime.value !== 'all') {
|
||
unscheduledSongs = unscheduledSongs.filter((song) => {
|
||
if (selectedFilterPlayTime.value === 'none') {
|
||
return !song.preferredPlayTimeId
|
||
}
|
||
return song.preferredPlayTimeId === selectedFilterPlayTime.value
|
||
})
|
||
}
|
||
|
||
// 歌单查重过滤
|
||
if (isPlaylistFilterActive.value && playlistFilterTrackIds.value.size > 0) {
|
||
unscheduledSongs = unscheduledSongs.filter((song) => {
|
||
// 仅在歌曲为网易云平台且其 ID 存在于过滤列表中时才保留
|
||
if (song.musicId && (song.musicPlatform === 'netease' || !song.musicPlatform)) {
|
||
return playlistFilterTrackIds.value.has(song.musicId.toString())
|
||
}
|
||
// 如果不是网易云歌曲,则在启用了网易云歌单过滤时直接排除(因为查重只查网易云)
|
||
return false
|
||
})
|
||
}
|
||
|
||
return [...unscheduledSongs].sort((a, b) => {
|
||
if (activeTab.value === 'pool') {
|
||
return new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
|
||
}
|
||
// 重播申请默认按申请数量降序排列,如果数量相同则按时间
|
||
if (activeTab.value === 'replay') {
|
||
if ((b.requestCount || 0) !== (a.requestCount || 0)) {
|
||
return (b.requestCount || 0) - (a.requestCount || 0)
|
||
}
|
||
}
|
||
|
||
switch (songSortOption.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)
|
||
default:
|
||
return 0
|
||
}
|
||
})
|
||
})
|
||
|
||
// 分页后的未排期歌曲
|
||
const filteredUnscheduledSongs = computed(() => {
|
||
const startIndex = (currentPage.value - 1) * pageSize.value
|
||
const endIndex = startIndex + pageSize.value
|
||
return allUnscheduledSongs.value.slice(startIndex, endIndex)
|
||
})
|
||
|
||
// 总页数
|
||
const totalPages = computed(() => {
|
||
return Math.ceil(allUnscheduledSongs.value.length / pageSize.value)
|
||
})
|
||
|
||
// 桌面端检测
|
||
const isDesktop = ref(true)
|
||
|
||
// 方法
|
||
const formatDate = (dateString) => {
|
||
if (!dateString) return locale.value?.timeAgo?.never || '从未'
|
||
const date = new Date(dateString)
|
||
if (Number.isNaN(date.getTime())) return locale.value?.timeAgo?.never || '从未'
|
||
const now = getSyncedDate()
|
||
const diff = now - date
|
||
|
||
if (diff < 60000) return locale.value.timeAgo.justNow
|
||
if (diff < 3600000) return locale.value.timeAgo.minutes(Math.floor(diff / 60000))
|
||
if (diff < 86400000) return locale.value.timeAgo.hours(Math.floor(diff / 3600000))
|
||
return locale.value.timeAgo.days(Math.floor(diff / 86400000))
|
||
}
|
||
|
||
// 检查窗口大小
|
||
const checkWindowSize = () => {
|
||
isDesktop.value = window.innerWidth >= 1024
|
||
}
|
||
|
||
let targetScrollLeft = null
|
||
let animationFrameId = null
|
||
|
||
// 自定义平滑滚动动画
|
||
const smoothScrollTo = (element, target, duration = 300) => {
|
||
if (!element) return
|
||
|
||
const start = element.scrollLeft
|
||
const distance = target - start
|
||
let startTime = null
|
||
|
||
if (animationFrameId) {
|
||
cancelAnimationFrame(animationFrameId)
|
||
}
|
||
|
||
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3)
|
||
|
||
const animation = (currentTime) => {
|
||
if (!startTime) startTime = currentTime
|
||
const timeElapsed = currentTime - startTime
|
||
const progress = Math.min(timeElapsed / duration, 1)
|
||
|
||
element.scrollLeft = start + distance * easeOutCubic(progress)
|
||
|
||
if (timeElapsed < duration) {
|
||
animationFrameId = requestAnimationFrame(animation)
|
||
} else {
|
||
targetScrollLeft = null
|
||
animationFrameId = null
|
||
}
|
||
}
|
||
|
||
animationFrameId = requestAnimationFrame(animation)
|
||
}
|
||
|
||
// 处理日期选择器滚轮事件
|
||
const handleDateSelectorWheel = (event) => {
|
||
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
|
||
return
|
||
}
|
||
|
||
event.preventDefault()
|
||
|
||
const isTouchpad = Math.abs(event.deltaY) < 50
|
||
|
||
if (isTouchpad) {
|
||
if (animationFrameId) {
|
||
cancelAnimationFrame(animationFrameId)
|
||
animationFrameId = null
|
||
targetScrollLeft = null
|
||
}
|
||
dateSelector.value.scrollLeft += event.deltaY
|
||
} else {
|
||
if (targetScrollLeft === null) {
|
||
targetScrollLeft = dateSelector.value.scrollLeft
|
||
}
|
||
|
||
const scrollAmount = event.deltaY > 0 ? 150 : -150
|
||
targetScrollLeft += scrollAmount
|
||
|
||
const maxScroll = dateSelector.value.scrollWidth - dateSelector.value.clientWidth
|
||
targetScrollLeft = Math.max(0, Math.min(targetScrollLeft, maxScroll))
|
||
|
||
smoothScrollTo(dateSelector.value, targetScrollLeft)
|
||
}
|
||
}
|
||
|
||
// 滚动日期选择器
|
||
const scrollDates = (direction) => {
|
||
if (!dateSelector.value) return
|
||
|
||
const scrollAmount = 200
|
||
const currentScroll = dateSelector.value.scrollLeft
|
||
|
||
if (targetScrollLeft === null) {
|
||
targetScrollLeft = currentScroll
|
||
}
|
||
|
||
targetScrollLeft =
|
||
direction === 'right' ? targetScrollLeft + scrollAmount : targetScrollLeft - scrollAmount
|
||
|
||
const maxScroll = dateSelector.value.scrollWidth - dateSelector.value.clientWidth
|
||
targetScrollLeft = Math.max(0, Math.min(targetScrollLeft, maxScroll))
|
||
|
||
smoothScrollTo(dateSelector.value, targetScrollLeft, 400)
|
||
}
|
||
|
||
let scrollTimeout = null
|
||
|
||
// 更新滚动按钮状态并加载更多日期
|
||
const updateScrollButtonState = () => {
|
||
if (!dateSelector.value) return
|
||
|
||
isFirstDateVisible.value = false
|
||
isLastDateVisible.value = false
|
||
|
||
const { scrollLeft, scrollWidth, clientWidth } = dateSelector.value
|
||
|
||
if (scrollLeft >= 50 && scrollWidth - scrollLeft - clientWidth >= 50) {
|
||
if (scrollTimeout) {
|
||
clearTimeout(scrollTimeout)
|
||
scrollTimeout = null
|
||
}
|
||
return
|
||
}
|
||
|
||
if (scrollTimeout) {
|
||
clearTimeout(scrollTimeout)
|
||
}
|
||
|
||
scrollTimeout = setTimeout(async () => {
|
||
if (!dateSelector.value) return
|
||
|
||
const currentScrollLeft = dateSelector.value.scrollLeft
|
||
const currentScrollWidth = dateSelector.value.scrollWidth
|
||
const currentClientWidth = dateSelector.value.clientWidth
|
||
|
||
if (currentScrollLeft < 50) {
|
||
const oldScrollWidth = currentScrollWidth
|
||
dateRange.value.start -= 14
|
||
|
||
await nextTick()
|
||
|
||
const newScrollWidth = dateSelector.value.scrollWidth
|
||
|
||
const delta = newScrollWidth - oldScrollWidth
|
||
dateSelector.value.scrollLeft = currentScrollLeft + delta
|
||
|
||
// 补偿正在进行的平滑滚动动画目标,避免跳跃或回弹
|
||
if (targetScrollLeft !== null) {
|
||
targetScrollLeft += delta
|
||
}
|
||
} else if (currentScrollWidth - currentScrollLeft - currentClientWidth < 50) {
|
||
dateRange.value.end += 14
|
||
}
|
||
}, 150)
|
||
}
|
||
|
||
// 确认对话框处理
|
||
const handleConfirm = async () => {
|
||
if (confirmAction.value) {
|
||
await confirmAction.value()
|
||
}
|
||
showConfirmDialog.value = false
|
||
confirmAction.value = null
|
||
}
|
||
|
||
// 监听浏览器刷新/关闭事件
|
||
const handleBeforeUnload = (e) => {
|
||
if (hasChanges.value) {
|
||
e.preventDefault()
|
||
e.returnValue = ''
|
||
return ''
|
||
}
|
||
}
|
||
|
||
// 监听路由离开事件
|
||
onBeforeRouteLeave((to, from, next) => {
|
||
if (hasChanges.value) {
|
||
const answer = window.confirm(locale.value.confirmations.leaveUnsaved)
|
||
if (answer) {
|
||
next()
|
||
} else {
|
||
next(false)
|
||
}
|
||
} else {
|
||
next()
|
||
}
|
||
})
|
||
|
||
// 处理日期选择
|
||
const handleDateSelect = (dateValue) => {
|
||
if (selectedDate.value === dateValue) return
|
||
|
||
if (hasChanges.value) {
|
||
if (!window.confirm(locale.value.confirmations.switchDateUnsaved)) {
|
||
return
|
||
}
|
||
}
|
||
selectedDate.value = dateValue
|
||
}
|
||
|
||
// 处理播出时段选择
|
||
const handlePlayTimeSelect = (value) => {
|
||
if (selectedPlayTime.value === value) return
|
||
|
||
if (hasChanges.value) {
|
||
if (!window.confirm(locale.value.confirmations.switchPlayTimeUnsaved)) {
|
||
return
|
||
}
|
||
}
|
||
selectedPlayTime.value = value
|
||
}
|
||
|
||
// 处理学期选择
|
||
const handleSemesterSelect = async (value) => {
|
||
if (selectedSemester.value === value) return
|
||
|
||
if (hasChanges.value) {
|
||
if (!window.confirm(locale.value.confirmations.switchSemesterUnsaved)) {
|
||
return
|
||
}
|
||
}
|
||
selectedSemester.value = value
|
||
await onSemesterChange()
|
||
}
|
||
|
||
// 初始化
|
||
let unregisterBeforeNavigate = null
|
||
const registerBeforeNavigate = inject('registerBeforeNavigate', null)
|
||
let suppressSelectedDateLoad = false
|
||
|
||
onMounted(async () => {
|
||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||
|
||
if (registerBeforeNavigate) {
|
||
unregisterBeforeNavigate = registerBeforeNavigate(() => {
|
||
if (hasChanges.value) {
|
||
return window.confirm(locale.value.confirmations.switchPageUnsaved)
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
|
||
songsService = useSongs()
|
||
adminService = useAdmin()
|
||
auth = useAuth()
|
||
semesterService = useSemesters()
|
||
|
||
await useSyncedTime().syncTime()
|
||
suppressSelectedDateLoad = true
|
||
selectedDate.value = getTodayDateValue()
|
||
await nextTick()
|
||
suppressSelectedDateLoad = false
|
||
|
||
// 初始化窗口大小检测
|
||
checkWindowSize()
|
||
window.addEventListener('resize', checkWindowSize)
|
||
|
||
// 立即滚动到当前日期,避免等待数据加载
|
||
nextTick(() => {
|
||
scrollToDateElement('auto')
|
||
})
|
||
|
||
// 先加载学期数据,然后加载其他数据
|
||
await loadSemesters()
|
||
await loadData()
|
||
|
||
// 添加事件监听器
|
||
nextTick(() => {
|
||
if (dateSelector.value) {
|
||
dateSelector.value.addEventListener('wheel', handleDateSelectorWheel, { passive: false })
|
||
dateSelector.value.addEventListener('scroll', updateScrollButtonState)
|
||
}
|
||
updateScrollButtonState()
|
||
|
||
// 再次确认滚动位置(防止布局偏移)
|
||
scrollToDateElement('auto')
|
||
})
|
||
|
||
// 自动排期弹窗 Esc 关闭
|
||
window.addEventListener('keydown', handleAutoScheduleEscape)
|
||
})
|
||
|
||
// 滚动到指定日期元素
|
||
const scrollToDateElement = (behavior = 'smooth') => {
|
||
if (!dateSelector.value || !selectedDate.value) return
|
||
|
||
const el = dateSelector.value.querySelector(`[data-date="${selectedDate.value}"]`)
|
||
if (el) {
|
||
if (behavior === 'smooth') {
|
||
const listRect = dateSelector.value.getBoundingClientRect()
|
||
const elRect = el.getBoundingClientRect()
|
||
const scrollLeft = dateSelector.value.scrollLeft
|
||
|
||
let target =
|
||
scrollLeft + (elRect.left - listRect.left) - listRect.width / 2 + elRect.width / 2
|
||
|
||
const maxScroll = dateSelector.value.scrollWidth - dateSelector.value.clientWidth
|
||
target = Math.max(0, Math.min(target, maxScroll))
|
||
|
||
targetScrollLeft = target
|
||
smoothScrollTo(dateSelector.value, target, 400)
|
||
} else {
|
||
el.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'center' })
|
||
}
|
||
}
|
||
}
|
||
|
||
// 清理事件监听器
|
||
onUnmounted(() => {
|
||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||
|
||
if (unregisterBeforeNavigate) {
|
||
unregisterBeforeNavigate()
|
||
}
|
||
|
||
// 组件卸载时中止批量刷新进行中的请求
|
||
if (refreshAllAbortController) {
|
||
refreshAllAbortController.abort()
|
||
refreshAllAbortController = null
|
||
}
|
||
if (refreshAutoCandidatesAbortController) {
|
||
refreshAutoCandidatesAbortController.abort()
|
||
refreshAutoCandidatesAbortController = null
|
||
}
|
||
|
||
if (dateSelector.value) {
|
||
dateSelector.value.removeEventListener('wheel', handleDateSelectorWheel)
|
||
dateSelector.value.removeEventListener('scroll', updateScrollButtonState)
|
||
}
|
||
window.removeEventListener('resize', checkWindowSize)
|
||
|
||
// 清理自动排期弹窗 Esc 监听
|
||
window.removeEventListener('keydown', handleAutoScheduleEscape)
|
||
|
||
if (animationFrameId) {
|
||
cancelAnimationFrame(animationFrameId)
|
||
animationFrameId = null
|
||
}
|
||
|
||
if (scrollTimeout) {
|
||
clearTimeout(scrollTimeout)
|
||
scrollTimeout = null
|
||
}
|
||
})
|
||
|
||
// 打开手动日期选择器
|
||
const openManualDatePicker = () => {
|
||
manualSelectedDate.value = selectedDate.value
|
||
showManualDatePicker.value = true
|
||
}
|
||
|
||
// 确认手动日期选择
|
||
const confirmManualDate = () => {
|
||
if (manualSelectedDate.value) {
|
||
if (selectedDate.value === manualSelectedDate.value) {
|
||
showManualDatePicker.value = false
|
||
return
|
||
}
|
||
|
||
if (hasChanges.value) {
|
||
if (!window.confirm(locale.value.confirmations.switchDateUnsaved)) {
|
||
return
|
||
}
|
||
}
|
||
|
||
selectedDate.value = manualSelectedDate.value
|
||
showManualDatePicker.value = false
|
||
|
||
// 选中日期后,如果是手动选择的日期可能在当前列表外,滚动到该日期
|
||
nextTick(() => {
|
||
scrollToDateElement('smooth')
|
||
})
|
||
}
|
||
}
|
||
|
||
// 定位到今天
|
||
const scrollToToday = () => {
|
||
const todayStr = getTodayDateValue()
|
||
const isAlreadyToday = selectedDate.value === todayStr
|
||
|
||
if (!isAlreadyToday) {
|
||
if (hasChanges.value && !window.confirm(locale.value.confirmations.switchDateUnsaved)) {
|
||
return
|
||
}
|
||
selectedDate.value = todayStr
|
||
}
|
||
|
||
// 确保今天在范围内
|
||
if (dateRange.value.start > 0 || dateRange.value.end < 0) {
|
||
dateRange.value.start = -15
|
||
dateRange.value.end = 15
|
||
}
|
||
|
||
nextTick(() => {
|
||
scrollToDateElement('smooth')
|
||
})
|
||
}
|
||
|
||
// 监听日期变化
|
||
watch(selectedDate, async () => {
|
||
if (suppressSelectedDateLoad) return
|
||
await loadData()
|
||
})
|
||
|
||
// 重置所有分页状态
|
||
const resetAllPages = () => {
|
||
pageStates.normal = 1
|
||
pageStates.replay = 1
|
||
pageStates.all = 1
|
||
pageStates.pool = 1
|
||
}
|
||
|
||
// 监听排序选项变化,重置分页
|
||
watch(songSortOption, () => {
|
||
resetAllPages()
|
||
})
|
||
|
||
// 监听搜索查询变化,重置分页
|
||
watch(searchQuery, () => {
|
||
resetAllPages()
|
||
})
|
||
|
||
// 监听年级筛选变化,重置分页
|
||
watch(selectedGrade, () => {
|
||
resetAllPages()
|
||
})
|
||
|
||
// 监听期望时段筛选变化,重置分页
|
||
watch(selectedFilterPlayTime, () => {
|
||
resetAllPages()
|
||
})
|
||
|
||
// 加载重播申请
|
||
const fetchReplayRequests = async () => {
|
||
try {
|
||
// 与歌曲列表一致,按当前选中学期过滤;选择"全部"时不传学期参数
|
||
const selectedSemesterOption = availableSemesters.value.find((item) => String(item.id) === String(selectedSemester.value))
|
||
const semester = selectedSemester.value === 'all' ? undefined : selectedSemesterOption?.name
|
||
const data = await $fetch('/api/admin/replay-requests', {
|
||
...auth.getAuthConfig(),
|
||
query: { status: 'PENDING', ...(semester ? { semester } : {}) }
|
||
})
|
||
replayRequests.value = data || []
|
||
} catch (err) {
|
||
console.error('Failed to fetch replay requests', err)
|
||
replayRequests.value = []
|
||
}
|
||
}
|
||
|
||
// 加载备选池
|
||
const fetchSongPool = async () => {
|
||
poolLoading.value = true
|
||
try {
|
||
const data = await $fetch('/api/admin/schedule/song-pool', {
|
||
...auth.getAuthConfig()
|
||
})
|
||
songPool.value = data?.pool || []
|
||
} catch (err) {
|
||
console.error('加载备选池失败:', err)
|
||
songPool.value = []
|
||
} finally {
|
||
poolLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 从待排库批量移入备选池
|
||
const moveAllToPool = async () => {
|
||
const pageSongs = filteredUnscheduledSongs.value.filter((song) => !poolSongIds.value.has(song.id))
|
||
const pendingSongIds = pageSongs.map((s) => s.id)
|
||
if (pendingSongIds.length === 0) return
|
||
|
||
confirmDialogTitle.value = locale.value.addCurrentPageConfirmTitle
|
||
confirmDialogMessage.value = locale.value.addCurrentPageConfirmMessage(pendingSongIds.length, currentPage.value)
|
||
confirmDialogType.value = 'warning'
|
||
confirmDialogConfirmText.value = locale.value.confirm
|
||
|
||
confirmAction.value = async () => {
|
||
try {
|
||
const songDurations = (await Promise.all(
|
||
pageSongs
|
||
.filter((song) => !song.durationSeconds && song.musicPlatform && song.musicId)
|
||
.map(async (song) => {
|
||
const durationSeconds = await resolveClientAudioDuration(song)
|
||
return durationSeconds ? { songId: song.id, durationSeconds } : null
|
||
})
|
||
)).filter(Boolean)
|
||
const result = await $fetch('/api/admin/schedule/song-pool', {
|
||
method: 'POST',
|
||
...auth.getAuthConfig(),
|
||
body: { songIds: pendingSongIds, songDurations }
|
||
})
|
||
await fetchSongPool()
|
||
const added = result.added || []
|
||
const skipped = result.skipped || []
|
||
if (added.length > 0) {
|
||
window.$showNotification && window.$showNotification(
|
||
`${locale.value.addCurrentPageSuccess(added.length)}`,
|
||
'success'
|
||
)
|
||
}
|
||
if (skipped.length > 0) {
|
||
const reasons = skipped.map((s) => s.reason).join('、')
|
||
window.$showNotification && window.$showNotification(
|
||
`${locale.value.addAllPendingSkipped(skipped.length)}(${reasons})`,
|
||
'warning'
|
||
)
|
||
}
|
||
} catch (err) {
|
||
console.error('移入备选池失败:', err)
|
||
window.$showNotification && window.$showNotification(
|
||
locale.value.addAllPendingFailed,
|
||
'error'
|
||
)
|
||
}
|
||
}
|
||
|
||
showConfirmDialog.value = true
|
||
}
|
||
|
||
// 单首加入备选池
|
||
const addSingleToPool = async (songId) => {
|
||
try {
|
||
const result = await $fetch('/api/admin/schedule/song-pool', {
|
||
method: 'POST',
|
||
...auth.getAuthConfig(),
|
||
body: { songIds: [songId] }
|
||
})
|
||
await fetchSongPool()
|
||
const added = result.added || []
|
||
const skipped = result.skipped || []
|
||
if (added.length > 0) {
|
||
window.$showNotification && window.$showNotification(
|
||
locale.value.addSingleToPoolSuccess,
|
||
'success'
|
||
)
|
||
} else if (skipped.length > 0) {
|
||
const reason = skipped[0]?.reason || ''
|
||
window.$showNotification && window.$showNotification(
|
||
`${locale.value.addSingleToPoolSkipped}(${reason})`,
|
||
'warning'
|
||
)
|
||
}
|
||
} catch (err) {
|
||
console.error('加入备选池失败:', err)
|
||
}
|
||
}
|
||
|
||
// 从备选池移除
|
||
const removeFromPool = async (songId) => {
|
||
try {
|
||
await $fetch('/api/admin/schedule/song-pool', {
|
||
method: 'DELETE',
|
||
...auth.getAuthConfig(),
|
||
query: { songIds: songId }
|
||
})
|
||
await fetchSongPool()
|
||
window.$showNotification && window.$showNotification(
|
||
locale.value.removeFromPoolSuccess,
|
||
'success'
|
||
)
|
||
} catch (err) {
|
||
console.error('从备选池移除失败:', err)
|
||
window.$showNotification && window.$showNotification(
|
||
locale.value.removeFromPoolFailed,
|
||
'error'
|
||
)
|
||
}
|
||
}
|
||
|
||
// 拒绝重播申请
|
||
const rejectReplayRequest = async (songId) => {
|
||
confirmDialogTitle.value = locale.value.confirmations.rejectReplayTitle
|
||
confirmDialogMessage.value = locale.value.confirmations.rejectReplayMessage
|
||
confirmDialogType.value = 'warning'
|
||
confirmDialogConfirmText.value = locale.value.rejectRequest
|
||
|
||
confirmAction.value = async () => {
|
||
try {
|
||
await $fetch('/api/admin/replay-requests/reject', {
|
||
method: 'POST',
|
||
body: { songId },
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
// 刷新申请列表
|
||
await fetchReplayRequests()
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages.replayRejected, 'success')
|
||
}
|
||
} catch (err) {
|
||
console.error('拒绝申请失败', err)
|
||
if (window.$showNotification) {
|
||
const message = getThrownMessage(err) || '未知错误'
|
||
window.$showNotification(
|
||
callLocale('errors.rejectReplayFailed', `拒绝申请失败: ${message}`, message),
|
||
'error'
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
showConfirmDialog.value = true
|
||
}
|
||
|
||
// 加载数据
|
||
const loadData = async () => {
|
||
loading.value = true
|
||
try {
|
||
// 使用选中的学期过滤歌曲,如果选择"全部"则不传递学期参数
|
||
const selectedSemesterOption = availableSemesters.value.find((item) => String(item.id) === String(selectedSemester.value))
|
||
const semester = selectedSemester.value === 'all' ? undefined : selectedSemesterOption?.name
|
||
|
||
// 播放列表应该显示所有学期的排期,不受待排歌曲学期选择的影响
|
||
// 因为在界面上我们是按日期(selectedDate)来过滤显示排期的
|
||
// 并行加载数据
|
||
await Promise.all([
|
||
songsService.fetchSongs(false, semester, true),
|
||
songsService.fetchPublicSchedules(false, undefined, true),
|
||
loadPlayTimes(),
|
||
loadDrafts(), // 加载草稿列表
|
||
fetchReplayRequests() // 加载重播申请
|
||
])
|
||
|
||
songs.value = songsService.songs.value
|
||
publicSchedules.value = songsService.publicSchedules.value
|
||
|
||
// 在草稿加载完成后再更新本地排期数据
|
||
updateLocalScheduledSongs()
|
||
hasChanges.value = false
|
||
// 加载备选池
|
||
await fetchSongPool()
|
||
} catch (error) {
|
||
console.error('加载数据失败:', error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 加载播出时段
|
||
const loadPlayTimes = async () => {
|
||
try {
|
||
const response = await $fetch('/api/play-times')
|
||
playTimeEnabled.value = response.enabled
|
||
playTimes.value = response.playTimes || []
|
||
} catch (error) {
|
||
console.error('加载播出时段失败:', error)
|
||
playTimeEnabled.value = false
|
||
playTimes.value = []
|
||
}
|
||
}
|
||
|
||
const readClientAudioDuration = (song, signal, sourceUrl = song?.playUrl) => {
|
||
if (!import.meta.client || !sourceUrl) return Promise.resolve(null)
|
||
|
||
return new Promise((resolve) => {
|
||
const audio = new Audio()
|
||
let settled = false
|
||
const timeoutId = window.setTimeout(() => finish(null), 8000)
|
||
|
||
const cleanup = () => {
|
||
window.clearTimeout(timeoutId)
|
||
audio.onloadedmetadata = null
|
||
audio.onerror = null
|
||
audio.src = ''
|
||
audio.load()
|
||
signal?.removeEventListener('abort', abort)
|
||
}
|
||
const finish = (duration) => {
|
||
if (settled) return
|
||
settled = true
|
||
cleanup()
|
||
resolve(Number.isFinite(duration) && duration >= 30 && duration <= 3600 ? Math.floor(duration) : null)
|
||
}
|
||
const abort = () => finish(null)
|
||
|
||
if (signal?.aborted) {
|
||
finish(null)
|
||
return
|
||
}
|
||
signal?.addEventListener('abort', abort, { once: true })
|
||
audio.preload = 'metadata'
|
||
audio.onloadedmetadata = () => finish(audio.duration)
|
||
audio.onerror = () => finish(null)
|
||
audio.src = convertToHttps(sourceUrl)
|
||
})
|
||
}
|
||
|
||
// 按播放器相同的音源解析顺序读取时长,失败后切换到其他音源。
|
||
const resolveClientAudioDuration = async (song, signal) => {
|
||
if (!import.meta.client || !song?.musicPlatform || !song?.musicId) return null
|
||
|
||
const excludeSources = []
|
||
const hasPlayUrl = Boolean(song.playUrl && String(song.playUrl).trim())
|
||
let ignoreProvidedUrl = !hasPlayUrl
|
||
const isPodcast =
|
||
song.musicPlatform === 'netease-podcast' ||
|
||
song.sourceInfo?.type === 'voice' ||
|
||
(song.sourceInfo?.source === 'netease-backup' && song.sourceInfo?.type === 'voice')
|
||
const mediaId = song.sourceInfo?.strMediaMid || song.sourceInfo?.mediaId || song.sourceInfo?.mediaMid
|
||
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
if (signal?.aborted) return null
|
||
|
||
let candidate
|
||
try {
|
||
candidate = await getMusicUrlResult(
|
||
song.musicPlatform,
|
||
song.musicId,
|
||
song.playUrl,
|
||
{
|
||
unblock: isPodcast ? false : undefined,
|
||
mediaId,
|
||
ignoreProvidedUrl,
|
||
excludeSources,
|
||
musicInfo: {
|
||
name: song.title,
|
||
artist: song.artist,
|
||
album: song.album || undefined
|
||
}
|
||
}
|
||
)
|
||
} catch {
|
||
if (!ignoreProvidedUrl && hasPlayUrl) {
|
||
ignoreProvidedUrl = true
|
||
continue
|
||
}
|
||
return null
|
||
}
|
||
|
||
const sourceUrl = candidate?.url
|
||
if (!sourceUrl || (song.musicPlatform === 'tencent' && isKnownInvalidQqAudioUrl(sourceUrl))) {
|
||
if (candidate?.source === 'play-url') {
|
||
ignoreProvidedUrl = true
|
||
} else if (candidate?.source && !excludeSources.includes(candidate.source)) {
|
||
excludeSources.push(candidate.source)
|
||
}
|
||
continue
|
||
}
|
||
|
||
const duration = await readClientAudioDuration(song, signal, sourceUrl)
|
||
if (signal?.aborted) return null
|
||
const isNetease = song.musicPlatform === 'netease' || song.musicPlatform === 'netease-podcast'
|
||
if (duration != null && !(isNetease && duration === 30)) {
|
||
return duration
|
||
}
|
||
|
||
if (candidate.source === 'play-url') {
|
||
ignoreProvidedUrl = true
|
||
} else if (candidate.source && !excludeSources.includes(candidate.source)) {
|
||
excludeSources.push(candidate.source)
|
||
} else {
|
||
ignoreProvidedUrl = true
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 并发执行任务列表,限制同时进行数。
|
||
* @param {Array<{fn:(signal)=>Promise, signal:AbortSignal}>} items
|
||
* @param {number} concurrency 并发数,默认 3
|
||
* @param {(done:number,total:number)=>void} [onProgress] 进度回调
|
||
* @returns {Promise<Array<{ok:boolean,result:any}>>} 保持原始顺序
|
||
*/
|
||
const runConcurrent = async (items, concurrency = 3, onProgress, onItemComplete) => {
|
||
const results = new Array(items.length)
|
||
let nextIndex = 0
|
||
let completed = 0
|
||
let aborted = false
|
||
|
||
const worker = async () => {
|
||
while (nextIndex < items.length && !aborted) {
|
||
const idx = nextIndex++
|
||
const item = items[idx]
|
||
let entry
|
||
try {
|
||
if (item.signal?.aborted) {
|
||
aborted = true
|
||
entry = { ok: false, result: null }
|
||
} else {
|
||
const res = await item.fn(item.signal)
|
||
entry = { ok: true, result: res }
|
||
}
|
||
} catch (err) {
|
||
entry = { ok: false, result: err }
|
||
}
|
||
results[idx] = entry
|
||
completed++
|
||
onProgress?.(completed, items.length)
|
||
onItemComplete?.(idx, entry)
|
||
}
|
||
}
|
||
|
||
const workers = []
|
||
for (let i = 0; i < Math.min(concurrency, items.length); i++) {
|
||
workers.push(worker())
|
||
}
|
||
|
||
await Promise.all(workers)
|
||
return results
|
||
}
|
||
|
||
const requestSongDuration = async (song, signal) => {
|
||
const clientDuration = await resolveClientAudioDuration(song, signal)
|
||
if (clientDuration != null) {
|
||
return await $fetch('/api/admin/songs/duration', {
|
||
method: 'POST',
|
||
body: { songId: song.id, durationSeconds: clientDuration },
|
||
signal,
|
||
...auth.getAuthConfig()
|
||
})
|
||
}
|
||
|
||
return await $fetch('/api/admin/songs/duration', {
|
||
method: 'POST',
|
||
body: { songId: song.id },
|
||
signal,
|
||
...auth.getAuthConfig()
|
||
})
|
||
}
|
||
|
||
// 刷新歌曲时长
|
||
const refreshDuration = async (song) => {
|
||
const songId = song.id
|
||
if (!song.musicPlatform || !song.musicId) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages.durationNoPlatform, 'warning')
|
||
}
|
||
return
|
||
}
|
||
|
||
// 清除上次刷新状态
|
||
delete durationRefreshStatus.value[songId]
|
||
refreshingDuration.value[songId] = true
|
||
try {
|
||
const result = await requestSongDuration(song)
|
||
|
||
if (result.success && result.durationSeconds) {
|
||
// 更新待排歌曲列表
|
||
const songIndex = songs.value.findIndex((s) => s.id === songId)
|
||
if (songIndex !== -1) {
|
||
songs.value[songIndex].durationSeconds = result.durationSeconds
|
||
}
|
||
// 更新已排歌曲列表
|
||
for (const schedule of localScheduledSongs.value) {
|
||
if (schedule.song && schedule.song.id === songId) {
|
||
schedule.song.durationSeconds = result.durationSeconds
|
||
break
|
||
}
|
||
}
|
||
// 更新备选池
|
||
for (const p of songPool.value) {
|
||
if (p.songId === songId) { p.durationSeconds = result.durationSeconds; break }
|
||
}
|
||
// 更新重播申请
|
||
for (const r of replayRequests.value) {
|
||
if (r.id === songId) { r.durationSeconds = result.durationSeconds; break }
|
||
}
|
||
// 标记成功,3 秒后清除颜色标记
|
||
durationRefreshStatus.value[songId] = 'success'
|
||
setTimeout(() => {
|
||
if (durationRefreshStatus.value[songId] === 'success') {
|
||
delete durationRefreshStatus.value[songId]
|
||
}
|
||
}, 3000)
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages.durationUpdated, 'success')
|
||
}
|
||
} else {
|
||
// 标记失败
|
||
durationRefreshStatus.value[songId] = 'error'
|
||
setTimeout(() => {
|
||
if (durationRefreshStatus.value[songId] === 'error') {
|
||
delete durationRefreshStatus.value[songId]
|
||
}
|
||
}, 3000)
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages.durationFailed, 'error')
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('刷新时长失败:', err)
|
||
// 标记失败
|
||
durationRefreshStatus.value[songId] = 'error'
|
||
setTimeout(() => {
|
||
if (durationRefreshStatus.value[songId] === 'error') {
|
||
delete durationRefreshStatus.value[songId]
|
||
}
|
||
}, 3000)
|
||
if (window.$showNotification) {
|
||
window.$showNotification(localizeServerError(err), 'error')
|
||
}
|
||
} finally {
|
||
delete refreshingDuration.value[songId]
|
||
}
|
||
}
|
||
|
||
// 批量刷新时长 AbortController
|
||
let refreshAllAbortController = null
|
||
let refreshAutoCandidatesAbortController = null
|
||
|
||
// 批量刷新歌曲时长
|
||
// source: 'pending' = 待排库(默认),'scheduled' = 播放顺序
|
||
const refreshAllDurations = async (source = 'pending') => {
|
||
// 中止上次未完成的批量刷新
|
||
refreshAllAbortController?.abort()
|
||
refreshAllAbortController = new AbortController()
|
||
const { signal } = refreshAllAbortController
|
||
|
||
// 根据 source 确定目标歌曲列表
|
||
const targets = []
|
||
if (source === 'scheduled') {
|
||
const seen = new Set()
|
||
for (const schedule of localScheduledSongs.value) {
|
||
const song = schedule.song
|
||
if (song?.musicPlatform && song?.musicId && !seen.has(song.id)) {
|
||
seen.add(song.id)
|
||
targets.push(song)
|
||
}
|
||
}
|
||
} else {
|
||
const seen = new Set()
|
||
for (const song of filteredUnscheduledSongs.value) {
|
||
if (song.musicPlatform && song.musicId && !seen.has(song.id)) {
|
||
seen.add(song.id)
|
||
targets.push(song)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (targets.length === 0) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(callLocale('allDurationsSkipped', '当前无可刷新时长的歌曲'), 'info')
|
||
}
|
||
return
|
||
}
|
||
|
||
const toRefresh = targets
|
||
|
||
refreshingAllDurations.value = {
|
||
running: true,
|
||
progress: callLocale('allDurationsProgressTotal', `${toRefresh.length} 首歌`, toRefresh.length),
|
||
done: 0,
|
||
total: toRefresh.length
|
||
}
|
||
let successCount = 0
|
||
let failCount = 0
|
||
|
||
// 将单首歌结果写入 UI 状态(先获取到的先显示)
|
||
const applyRefreshResult = (song, result) => {
|
||
if (result.success && result.durationSeconds) {
|
||
const dur = result.durationSeconds
|
||
// 更新已排歌曲列表
|
||
for (const schedule of localScheduledSongs.value) {
|
||
if (schedule.song && schedule.song.id === song.id) {
|
||
schedule.song.durationSeconds = dur
|
||
break
|
||
}
|
||
}
|
||
// 更新待排歌曲列表
|
||
const songIndex = songs.value.findIndex((s) => s.id === song.id)
|
||
if (songIndex !== -1) {
|
||
songs.value[songIndex].durationSeconds = dur
|
||
}
|
||
for (const poolItem of songPool.value) {
|
||
if (poolItem.songId === song.id) poolItem.durationSeconds = dur
|
||
}
|
||
// 标记成功(颜色统一在全部完成后清除)
|
||
durationRefreshStatus.value[song.id] = 'success'
|
||
successCount++
|
||
} else {
|
||
// 标记失败
|
||
durationRefreshStatus.value[song.id] = 'error'
|
||
failCount++
|
||
}
|
||
}
|
||
|
||
const resultsPromise = runConcurrent(
|
||
toRefresh.map((song) => ({
|
||
fn: async (sig) => {
|
||
if (sig.aborted) return { songId: song.id, aborted: true }
|
||
const r = await requestSongDuration(song, sig)
|
||
return { songId: song.id, ...r }
|
||
},
|
||
signal
|
||
})),
|
||
3,
|
||
(completed) => {
|
||
refreshingAllDurations.value.done = completed
|
||
},
|
||
(idx, entry) => {
|
||
const song = toRefresh[idx]
|
||
if (!song || !entry || entry.aborted) return
|
||
applyRefreshResult(song, entry.result)
|
||
// 同时更新完成数和进度提示
|
||
refreshingAllDurations.value.done = successCount + failCount
|
||
refreshingAllDurations.value.progress = callLocale(
|
||
'allDurationsProgressWithCount',
|
||
`${successCount + failCount}/${toRefresh.length}(成功${successCount} 失败${failCount})`,
|
||
`${successCount + failCount}`, `${toRefresh.length}`, `${successCount}`, `${failCount}`
|
||
)
|
||
}
|
||
)
|
||
|
||
try {
|
||
// 等所有歌曲处理完
|
||
await resultsPromise
|
||
} finally {
|
||
// 等待 2 秒后统一清除颜色标记
|
||
await new Promise((r) => setTimeout(r, 2000))
|
||
for (const song of toRefresh) {
|
||
delete durationRefreshStatus.value[song.id]
|
||
}
|
||
refreshAllAbortController = null
|
||
refreshingAllDurations.value = { running: false, progress: '', done: 0, total: 0 }
|
||
}
|
||
|
||
if (window.$showNotification) {
|
||
if (successCount > 0 && failCount === 0) {
|
||
window.$showNotification(locale.value.messages.allDurationsUpdated(successCount), 'success')
|
||
} else if (successCount > 0 && failCount > 0) {
|
||
window.$showNotification(locale.value.messages.allDurationsPartial(successCount, failCount), 'warning')
|
||
} else {
|
||
window.$showNotification(locale.value.messages.allDurationsFailed, 'error')
|
||
}
|
||
}
|
||
}
|
||
|
||
// 刷新自动排期候选歌曲的时长
|
||
const refreshAutoCandidateDurations = async () => {
|
||
// 中止上次未完成的刷新
|
||
refreshAutoCandidatesAbortController?.abort()
|
||
refreshAutoCandidatesAbortController = new AbortController()
|
||
const { signal } = refreshAutoCandidatesAbortController
|
||
|
||
const candidates = autoScheduleCandidates.value
|
||
if (candidates.length === 0) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages.candidateDurationsSkipped, 'info')
|
||
}
|
||
return
|
||
}
|
||
|
||
const toRefresh = candidates.filter((s) => s.musicPlatform && s.musicId)
|
||
if (toRefresh.length === 0) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages.candidateDurationsSkipped, 'info')
|
||
}
|
||
return
|
||
}
|
||
|
||
refreshingAutoCandidates.value = {
|
||
running: true,
|
||
progress: callLocale('candidateDurationsProgressTotal', `${toRefresh.length} 首歌`, `${toRefresh.length} songs`),
|
||
success: 0,
|
||
fail: 0
|
||
}
|
||
let successCount = 0
|
||
let failCount = 0
|
||
|
||
// 将单首歌结果写入 UI 状态(先获取到的先显示)
|
||
const applyRefreshResult = (song, result) => {
|
||
if (result.success && result.durationSeconds) {
|
||
const dur = result.durationSeconds
|
||
const songIdx = songs.value.findIndex((s) => s.id === song.id)
|
||
if (songIdx !== -1) songs.value[songIdx].durationSeconds = dur
|
||
for (const r of replayRequests.value) {
|
||
if (r.id === song.id) { r.durationSeconds = dur; break }
|
||
}
|
||
for (const p of songPool.value) {
|
||
if (p.songId === song.id) { p.durationSeconds = dur; break }
|
||
}
|
||
durationRefreshStatus.value[song.id] = 'success'
|
||
successCount++
|
||
} else {
|
||
durationRefreshStatus.value[song.id] = 'error'
|
||
failCount++
|
||
}
|
||
}
|
||
|
||
const resultsPromise = runConcurrent(
|
||
toRefresh.map((song) => ({
|
||
fn: async (sig) => {
|
||
if (sig.aborted) return { songId: song.id, aborted: true }
|
||
const r = await requestSongDuration(song, sig)
|
||
return { songId: song.id, ...r }
|
||
},
|
||
signal
|
||
})),
|
||
3,
|
||
(completed) => {
|
||
refreshingAutoCandidates.value.success = completed - failCount
|
||
refreshingAutoCandidates.value.fail = failCount
|
||
refreshingAutoCandidates.value.progress = `${completed} / ${toRefresh.length}`
|
||
},
|
||
(idx, entry) => {
|
||
const song = toRefresh[idx]
|
||
if (!song || !entry || entry.aborted) return
|
||
applyRefreshResult(song, entry.result)
|
||
refreshingAutoCandidates.value.success = successCount
|
||
refreshingAutoCandidates.value.fail = failCount
|
||
refreshingAutoCandidates.value.progress = `${successCount + failCount} / ${toRefresh.length}`
|
||
}
|
||
)
|
||
|
||
try {
|
||
// 等所有歌曲处理完
|
||
await resultsPromise
|
||
} finally {
|
||
// 等待 2 秒后统一清除颜色标记
|
||
await new Promise((r) => setTimeout(r, 2000))
|
||
for (const song of toRefresh) {
|
||
delete durationRefreshStatus.value[song.id]
|
||
}
|
||
refreshAutoCandidatesAbortController = null
|
||
refreshingAutoCandidates.value = { running: false, progress: '' }
|
||
}
|
||
|
||
if (window.$showNotification) {
|
||
if (successCount > 0 && failCount === 0) {
|
||
window.$showNotification(locale.value.messages.candidateDurationsUpdated(successCount), 'success')
|
||
} else if (successCount > 0 && failCount > 0) {
|
||
window.$showNotification(locale.value.messages.candidateDurationsPartial(successCount, failCount), 'warning')
|
||
} else {
|
||
window.$showNotification(locale.value.messages.candidateDurationsFailed, 'error')
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 自动排期 =====
|
||
// 自动排期弹窗 Esc 关闭
|
||
const handleAutoScheduleEscape = (e) => {
|
||
if (e.key === 'Escape' && showAutoScheduleDialog.value) {
|
||
closeAutoScheduleDialog()
|
||
}
|
||
}
|
||
|
||
// 打开/关闭/重置时保留用户上次的排期配置(由 localStorage 记忆)
|
||
const openAutoScheduleDialog = () => {
|
||
autoScheduleResult.value = { songs: [], totalDuration: 0, diff: 0, absDiff: 0 }
|
||
autoSchedulePlans.value = []
|
||
currentPlanIndex.value = 0
|
||
showAutoScheduleDialog.value = true
|
||
}
|
||
const closeAutoScheduleDialog = () => {
|
||
showAutoScheduleDialog.value = false
|
||
autoScheduleResult.value = { songs: [], totalDuration: 0, diff: 0, absDiff: 0 }
|
||
autoSchedulePlans.value = []
|
||
currentPlanIndex.value = 0
|
||
}
|
||
const resetAutoSchedule = () => {
|
||
autoScheduleResult.value = { songs: [], totalDuration: 0, diff: 0, absDiff: 0 }
|
||
autoSchedulePlans.value = []
|
||
currentPlanIndex.value = 0
|
||
}
|
||
|
||
const autoScheduleCandidates = computed(() => {
|
||
const scheduledIds = new Set(localScheduledSongs.value.map((s) => s.song && s.song.id).filter(Boolean))
|
||
|
||
// 已加入其他日期草稿的歌曲,排除出自动排期范围
|
||
const otherDateDraftIds = new Set(
|
||
drafts.value
|
||
.filter((d) => d.song && d.song.id && d.playDate && getScheduleDateValue(d.playDate) !== selectedDate.value)
|
||
.map((d) => d.song.id)
|
||
)
|
||
const excludeIds = new Set([...scheduledIds, ...otherDateDraftIds])
|
||
|
||
// 备选池模式:复用备选池统一过滤(排除已排期/已播放),与备选池展示口径一致
|
||
if (activeTab.value === 'pool') {
|
||
return filteredPoolSongs.value
|
||
.filter((item) => !excludeIds.has(item.songId))
|
||
.map(poolCandidateFromItem)
|
||
}
|
||
|
||
// 待排库/重播/所有:复用 allUnscheduledSongs 的过滤逻辑,再排除已排期歌曲
|
||
const base = allUnscheduledSongs.value.filter((s) => !excludeIds.has(s.id))
|
||
if (activeTab.value === 'all') {
|
||
// 「所有」额外纳入备选池未排期且未播放的歌曲
|
||
const poolCandidates = filteredPoolSongs.value
|
||
.filter((item) => !excludeIds.has(item.songId))
|
||
.map(poolCandidateFromItem)
|
||
const baseIds = new Set(base.map((s) => s.id))
|
||
return [...base, ...poolCandidates.filter((p) => !baseIds.has(p.songId))]
|
||
}
|
||
return base
|
||
})
|
||
|
||
const buildPreSelected = () => {
|
||
return autoScheduleFixExisting.value ? localScheduledSongs.value.filter(
|
||
(s) => typeof s.song.durationSeconds === 'number' && s.song.durationSeconds > 0
|
||
).map((s) => ({
|
||
id: s.song.id,
|
||
songId: s.song.id,
|
||
title: s.song.title,
|
||
artist: s.song.artist,
|
||
durationSeconds: s.song.durationSeconds || 0,
|
||
replayRequestId: s.replayRequestId || null,
|
||
musicId: s.song.musicId || null,
|
||
musicPlatform: s.song.musicPlatform || null,
|
||
requester: s.song.requester || null,
|
||
cover: s.song.cover || null,
|
||
createdAt: s.song.createdAt || null
|
||
})) : []
|
||
}
|
||
|
||
const runAutoSchedule = () => {
|
||
const candidates = autoScheduleCandidates.value
|
||
const candidateIds = new Set(candidates.map((s) => s.id))
|
||
const preSelected = buildPreSelected()
|
||
const fn = autoScheduleAlgorithm.value === 'auto'
|
||
? (autoScheduleCandidates.value.length < 20 ? autoScheduleExhaustive : autoSchedule)
|
||
: (autoScheduleAlgorithm.value === 'exhaustive' ? autoScheduleExhaustive : autoSchedule)
|
||
const targetSongCount = Number.isFinite(autoScheduleTargetSongCount.value) && autoScheduleTargetSongCount.value > 0
|
||
? Math.floor(autoScheduleTargetSongCount.value)
|
||
: null
|
||
const targetRequesterCount = Number.isFinite(autoScheduleTargetRequesterCount.value) && autoScheduleTargetRequesterCount.value > 0
|
||
? Math.floor(autoScheduleTargetRequesterCount.value)
|
||
: null
|
||
const results = fn(autoScheduleDirection.value, autoScheduleTargetMinutes.value, candidates, preSelected, 10, targetSongCount, targetRequesterCount)
|
||
const plansArray = Array.isArray(results) ? results : [results]
|
||
const first = plansArray[0]
|
||
if (!first || first.songs.length === 0) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(callLocale('messages.autoScheduleNoResult', '未能找到满足条件的歌曲组合'), 'warning')
|
||
}
|
||
return
|
||
}
|
||
autoSchedulePlans.value = plansArray.map((p) => ({ ...p, candidateIds }))
|
||
currentPlanIndex.value = 0
|
||
autoScheduleResult.value = autoSchedulePlans.value[0]
|
||
}
|
||
|
||
const generateMorePlans = async () => {
|
||
if (generatingNewPlan.value) return
|
||
generatingNewPlan.value = true
|
||
const candidates = autoScheduleCandidates.value
|
||
const candidateIds = new Set(candidates.map((s) => s.id))
|
||
const preSelected = buildPreSelected()
|
||
|
||
// 动态 plansCount:已有方案数 + 10,确保新增返回新的解
|
||
const requestCount = autoSchedulePlans.value.length + 10
|
||
const fn = autoScheduleAlgorithm.value === 'auto'
|
||
? (autoScheduleCandidates.value.length < 20 ? autoScheduleExhaustive : autoSchedule)
|
||
: (autoScheduleAlgorithm.value === 'exhaustive' ? autoScheduleExhaustive : autoSchedule)
|
||
const targetSongCount = Number.isFinite(autoScheduleTargetSongCount.value) && autoScheduleTargetSongCount.value > 0
|
||
? Math.floor(autoScheduleTargetSongCount.value)
|
||
: null
|
||
const targetRequesterCount = Number.isFinite(autoScheduleTargetRequesterCount.value) && autoScheduleTargetRequesterCount.value > 0
|
||
? Math.floor(autoScheduleTargetRequesterCount.value)
|
||
: null
|
||
const results = fn(autoScheduleDirection.value, autoScheduleTargetMinutes.value, candidates, preSelected, requestCount, targetSongCount, targetRequesterCount)
|
||
const plansArray = Array.isArray(results) ? results : [results]
|
||
const existingKeys = new Set(autoSchedulePlans.value.map((p) => p.songs.map((s) => s.id).sort().join(',')))
|
||
let addedCount = 0
|
||
let firstNewIndex = 0
|
||
for (const plan of plansArray) {
|
||
const key = plan.songs.map((s) => s.id).sort().join(',')
|
||
if (!existingKeys.has(key) && plan.songs.length > 0) {
|
||
autoSchedulePlans.value.push({ ...plan, candidateIds })
|
||
existingKeys.add(key)
|
||
if (addedCount === 0) firstNewIndex = autoSchedulePlans.value.length - 1
|
||
addedCount++
|
||
}
|
||
}
|
||
if (addedCount > 0) {
|
||
// 跳到第一个新增方案,不跳末尾
|
||
currentPlanIndex.value = firstNewIndex
|
||
autoScheduleResult.value = autoSchedulePlans.value[currentPlanIndex.value]
|
||
} else if (window.$showNotification) {
|
||
window.$showNotification(callLocale('messages.autoScheduleNoMorePlans', '已无更多不同方案'), 'warning')
|
||
}
|
||
generatingNewPlan.value = false
|
||
}
|
||
|
||
// 切换到上一个方案
|
||
const goToPreviousPlan = () => {
|
||
if (currentPlanIndex.value > 0) {
|
||
currentPlanIndex.value--
|
||
autoScheduleResult.value = autoSchedulePlans.value[currentPlanIndex.value]
|
||
}
|
||
}
|
||
|
||
// 切换到下一个方案;穷举模式下到末尾则新增方案
|
||
const goToNextPlan = () => {
|
||
if (currentPlanIndex.value < autoSchedulePlans.value.length - 1) {
|
||
currentPlanIndex.value++
|
||
autoScheduleResult.value = autoSchedulePlans.value[currentPlanIndex.value]
|
||
} else if (actualExhaustive.value) {
|
||
generateMorePlans()
|
||
}
|
||
}
|
||
|
||
const confirmAutoSchedule = () => {
|
||
const candidateIds = autoScheduleResult.value.candidateIds || new Set()
|
||
const confirmed = autoScheduleResult.value.songs.filter((song) =>
|
||
candidateIds.has(song.id)
|
||
)
|
||
const baseId = Date.now()
|
||
let appliedCount = 0
|
||
let appliedTotalSec = 0
|
||
for (let i = 0; i < confirmed.length; i++) {
|
||
const song = confirmed[i]
|
||
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song.id === song.id)
|
||
if (existingIndex !== -1) continue
|
||
|
||
const newSchedule = {
|
||
id: baseId + i,
|
||
replayRequestId: song.replayRequestId || null,
|
||
song,
|
||
playDate: selectedDate.value,
|
||
sequence: localScheduledSongs.value.length + 1,
|
||
isNew: true,
|
||
isLocalOnly: true
|
||
}
|
||
scheduledSongIds.value.add(song.id)
|
||
setSongScheduledFlag(song.id, true)
|
||
localScheduledSongs.value.push(newSchedule)
|
||
hasChanges.value = true
|
||
appliedCount++
|
||
appliedTotalSec += song.durationSeconds
|
||
}
|
||
if (appliedCount > 0 && window.$showNotification) {
|
||
window.$showNotification(
|
||
locale.value.messages.confirmAutoScheduleApplied(appliedCount, formatDuration(appliedTotalSec)),
|
||
'success'
|
||
)
|
||
}
|
||
closeAutoScheduleDialog()
|
||
}
|
||
|
||
// 格式化播出时段时间范围
|
||
const formatPlayTimeRange = (playTime) => {
|
||
if (!playTime) return ''
|
||
|
||
const start = playTime.startTime || '00:00'
|
||
const end = playTime.endTime || '23:59'
|
||
|
||
if (playTime.startTime && playTime.endTime) {
|
||
return `${start} - ${end}`
|
||
} else if (playTime.startTime) {
|
||
return callLocale('playTimeStart', `${start} 开始`, start)
|
||
} else if (playTime.endTime) {
|
||
return callLocale('playTimeEnd', `${end} 结束`, end)
|
||
}
|
||
|
||
return locale.value.allDay
|
||
}
|
||
|
||
// 获取播出时段名称
|
||
const getPlayTimeName = (playTimeId) => {
|
||
if (!playTimeId || !playTimes.value) return ''
|
||
const playTime = playTimes.value.find((pt) => pt.id === playTimeId)
|
||
if (!playTime) return ''
|
||
|
||
let label = playTime.name
|
||
if (playTime.startTime || playTime.endTime) {
|
||
label += ` (${formatPlayTimeRange(playTime)})`
|
||
}
|
||
return label
|
||
}
|
||
|
||
// 加载学期列表
|
||
const loadSemesters = async () => {
|
||
try {
|
||
await semesterService.fetchSemesters()
|
||
await semesterService.fetchCurrentSemester()
|
||
|
||
// 构建学期列表,包含"全部"选项和各个学期
|
||
const semesterList = [{ id: 'all', name: locale.value.allSemesters, isCurrent: false }]
|
||
|
||
// 添加当前学期(如果存在)
|
||
if (semesterService.currentSemester.value) {
|
||
semesterList.push({
|
||
id: semesterService.currentSemester.value.id || 'current',
|
||
name: semesterService.currentSemester.value.name,
|
||
isCurrent: true
|
||
})
|
||
}
|
||
|
||
// 添加其他学期
|
||
if (semesterService.semesters.value) {
|
||
semesterService.semesters.value.forEach((semester) => {
|
||
if (
|
||
!semesterService.currentSemester.value ||
|
||
semester.name !== semesterService.currentSemester.value.name
|
||
) {
|
||
semesterList.push({
|
||
id: semester.id,
|
||
name: semester.name,
|
||
isCurrent: false
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
availableSemesters.value = semesterList
|
||
|
||
// 默认选择当前学期(如果存在),否则选择"全部"
|
||
if (semesterService.currentSemester.value) {
|
||
selectedSemester.value = semesterService.currentSemester.value.id || 'current'
|
||
} else if (semesterList.length > 0) {
|
||
selectedSemester.value = semesterList[0].id
|
||
}
|
||
} catch (error) {
|
||
console.error('获取学期列表失败:', error)
|
||
}
|
||
}
|
||
|
||
// 学期切换处理
|
||
const onSemesterChange = async () => {
|
||
// 学期切换后重新加载数据
|
||
await loadData()
|
||
}
|
||
|
||
// 更新本地排期数据(包括草稿)
|
||
const updateLocalScheduledSongs = () => {
|
||
console.log('更新本地排期数据 - 当前日期:', selectedDate.value)
|
||
console.log('公开排期数量:', publicSchedules.value.length)
|
||
console.log('草稿数量:', drafts.value.length)
|
||
|
||
// 获取已发布的排期
|
||
const todaySchedules = publicSchedules.value.filter((s) => {
|
||
if (!s.playDate) return false
|
||
const scheduleDateStr = getScheduleDateValue(s.playDate)
|
||
return scheduleDateStr === selectedDate.value
|
||
})
|
||
|
||
// 获取草稿排期
|
||
const todayDrafts = drafts.value.filter((draft) => {
|
||
if (!draft.playDate) return false
|
||
const draftDateStr = getScheduleDateValue(draft.playDate)
|
||
return draftDateStr === selectedDate.value
|
||
})
|
||
|
||
console.log('当天已发布排期:', todaySchedules.length)
|
||
console.log('当天草稿排期:', todayDrafts.length)
|
||
|
||
// 合并已发布和草稿排期
|
||
let allSchedules = [...todaySchedules, ...todayDrafts]
|
||
|
||
// 如果选择了特定播出时段,进行过滤
|
||
if (selectedPlayTime.value) {
|
||
allSchedules = allSchedules.filter((s) => s.playTimeId === parseInt(selectedPlayTime.value))
|
||
}
|
||
|
||
// 按 sequence 字段排序
|
||
allSchedules.sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
|
||
|
||
localScheduledSongs.value = allSchedules.map((s) => ({ ...s }))
|
||
|
||
console.log('最终显示排期数量:', localScheduledSongs.value.length)
|
||
|
||
// 更新已排期歌曲ID集合(包括草稿)
|
||
scheduledSongIds.value = new Set(
|
||
[...publicSchedules.value, ...drafts.value]
|
||
.filter((s) => s.song && s.song.id)
|
||
.map((s) => s.song.id)
|
||
)
|
||
}
|
||
|
||
// 监听播出时段选择变化
|
||
watch(selectedPlayTime, () => {
|
||
updateLocalScheduledSongs()
|
||
})
|
||
|
||
// 拖拽方法
|
||
const dragStart = (event, song) => {
|
||
event.dataTransfer.setData(
|
||
'text/plain',
|
||
JSON.stringify({
|
||
type: 'add-to-schedule',
|
||
songId: song.id,
|
||
replayRequestId: song.replayRequestId || null
|
||
})
|
||
)
|
||
|
||
setTimeout(() => {
|
||
event.target.classList.add('opacity-50')
|
||
}, 0)
|
||
}
|
||
|
||
const dragScheduleStart = (event, schedule) => {
|
||
event.dataTransfer.setData(
|
||
'text/plain',
|
||
JSON.stringify({
|
||
type: 'reorder-schedule',
|
||
scheduleId: schedule.id
|
||
})
|
||
)
|
||
|
||
draggedSchedule.value = { ...schedule }
|
||
|
||
setTimeout(() => {
|
||
event.target.classList.add('opacity-50')
|
||
}, 0)
|
||
}
|
||
|
||
const dragEnd = (event) => {
|
||
event.target.classList.remove('opacity-50')
|
||
dragOverIndex.value = -1
|
||
isSequenceOver.value = false
|
||
isDraggableOver.value = false
|
||
}
|
||
|
||
const handleDragOver = (event) => {
|
||
event.preventDefault()
|
||
isSequenceOver.value = true
|
||
}
|
||
|
||
const handleDragEnter = (event, index) => {
|
||
dragOverIndex.value = index
|
||
}
|
||
|
||
const handleDragLeave = (event) => {
|
||
if (!event.relatedTarget || !event.currentTarget.contains(event.relatedTarget)) {
|
||
dragOverIndex.value = -1
|
||
}
|
||
}
|
||
|
||
const handleSequenceDragLeave = (event) => {
|
||
if (!event.relatedTarget || !event.currentTarget.contains(event.relatedTarget)) {
|
||
isSequenceOver.value = false
|
||
}
|
||
}
|
||
|
||
const handleDraggableDragOver = (event) => {
|
||
event.preventDefault()
|
||
isDraggableOver.value = true
|
||
}
|
||
|
||
const handleDraggableDragLeave = (event) => {
|
||
if (!event.relatedTarget || !event.currentTarget.contains(event.relatedTarget)) {
|
||
isDraggableOver.value = false
|
||
}
|
||
}
|
||
|
||
const dropToSequence = async (event) => {
|
||
event.preventDefault()
|
||
dragOverIndex.value = -1
|
||
isSequenceOver.value = false
|
||
|
||
try {
|
||
const data = event.dataTransfer.getData('text/plain')
|
||
if (!data) return
|
||
|
||
const dragData = JSON.parse(data)
|
||
|
||
if (dragData.type === 'add-to-schedule') {
|
||
addSongToScheduleFromDrag(event)
|
||
}
|
||
} catch (err) {
|
||
console.error('处理拖放失败:', err)
|
||
}
|
||
}
|
||
|
||
const dropReorder = async (event, dropIndex) => {
|
||
event.preventDefault()
|
||
dragOverIndex.value = -1
|
||
|
||
try {
|
||
const data = event.dataTransfer.getData('text/plain')
|
||
if (!data) return
|
||
const dragData = JSON.parse(data)
|
||
|
||
if (dragData.type === 'reorder-schedule' && draggedSchedule.value) {
|
||
const scheduleId = parseInt(dragData.scheduleId)
|
||
const draggedIndex = localScheduledSongs.value.findIndex((s) => s.id === scheduleId)
|
||
|
||
if (draggedIndex === -1 || draggedIndex === dropIndex) return
|
||
|
||
const newOrder = [...localScheduledSongs.value]
|
||
const [draggedItem] = newOrder.splice(draggedIndex, 1)
|
||
newOrder.splice(dropIndex, 0, draggedItem)
|
||
|
||
newOrder.forEach((item, index) => {
|
||
item.sequence = index + 1
|
||
})
|
||
|
||
localScheduledSongs.value = newOrder
|
||
hasChanges.value = true
|
||
} else if (dragData.type === 'add-to-schedule') {
|
||
insertSongToScheduleAt(event, dropIndex)
|
||
}
|
||
} catch (err) {
|
||
console.error('处理重排序失败:', err)
|
||
}
|
||
|
||
draggedSchedule.value = null
|
||
}
|
||
|
||
// 同步歌曲列表中对应歌曲的已排期标记(排期与歌曲列表中的歌曲是不同引用)
|
||
const setSongScheduledFlag = (songId, scheduled) => {
|
||
const songInList = songs.value.find((s) => s.id === songId)
|
||
if (songInList) songInList.scheduled = scheduled
|
||
}
|
||
|
||
// 从拖拽数据中查找歌曲(优先查 songs/replayRequests,再回退到 pool)
|
||
const findSongFromDragData = (dragData) => {
|
||
const songId = parseInt(dragData.songId)
|
||
const isReplayRequest = dragData.replayRequestId != null
|
||
let song = isReplayRequest
|
||
? replayRequests.value.find((s) => s.replayRequestId === dragData.replayRequestId)
|
||
: songs.value.find((s) => s.id === songId)
|
||
if (!song) {
|
||
song = replayRequests.value.find((s) => s.id === songId)
|
||
}
|
||
if (!song) {
|
||
const poolItem = songPool.value.find((p) => p.songId === songId)
|
||
if (poolItem) {
|
||
song = {
|
||
id: poolItem.songId,
|
||
title: poolItem.title,
|
||
artist: poolItem.artist,
|
||
durationSeconds: poolItem.durationSeconds || null,
|
||
cover: poolItem.cover || null,
|
||
musicId: poolItem.musicId || null,
|
||
musicPlatform: poolItem.musicPlatform || null,
|
||
requester: poolItem.requester || null,
|
||
requesterId: poolItem.requesterId || null,
|
||
requesterGrade: poolItem.requesterGrade || null,
|
||
requesterClass: poolItem.requesterClass || null,
|
||
grade: poolItem.grade || null,
|
||
class: poolItem["class"] || null,
|
||
voteCount: poolItem.voteCount || 0,
|
||
cardCodeId: poolItem.cardCodeId || null,
|
||
usedCardCode: poolItem.usedCardCode || false,
|
||
hasSubmissionNote: poolItem.hasSubmissionNote || false,
|
||
submissionNote: poolItem.submissionNote || null,
|
||
preferredPlayTimeId: poolItem.preferredPlayTimeId || null,
|
||
semester: poolItem.semester || null
|
||
}
|
||
}
|
||
}
|
||
return { song, songId }
|
||
}
|
||
|
||
// 从拖拽数据中提取歌曲并添加到排期列表末尾(共享逻辑)
|
||
const addSongToScheduleFromDrag = (event) => {
|
||
const { song, songId } = findSongFromDragData(JSON.parse(event.dataTransfer.getData('text/plain')) || {})
|
||
if (!song) return
|
||
|
||
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song.id === songId)
|
||
if (existingIndex !== -1) return
|
||
|
||
const insertIndex = localScheduledSongs.value.length
|
||
|
||
const newSchedule = {
|
||
id: Date.now(),
|
||
replayRequestId: song.replayRequestId || null,
|
||
song: song,
|
||
playDate: selectedDate.value,
|
||
sequence: insertIndex + 1,
|
||
isNew: true,
|
||
isLocalOnly: true
|
||
}
|
||
|
||
scheduledSongIds.value.add(songId)
|
||
setSongScheduledFlag(songId, true)
|
||
localScheduledSongs.value.push(newSchedule)
|
||
hasChanges.value = true
|
||
}
|
||
|
||
// 从拖拽数据中提取歌曲并插入到指定位置(共享逻辑)
|
||
const insertSongToScheduleAt = (event, dropIndex) => {
|
||
const dragData = JSON.parse(event.dataTransfer.getData('text/plain'))
|
||
const { song, songId } = findSongFromDragData(dragData)
|
||
if (!song) return
|
||
|
||
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song.id === songId)
|
||
if (existingIndex !== -1) return
|
||
|
||
const newSchedule = {
|
||
id: Date.now(),
|
||
replayRequestId: song.replayRequestId || null,
|
||
song: song,
|
||
playDate: selectedDate.value,
|
||
sequence: dropIndex + 1,
|
||
isNew: true
|
||
}
|
||
|
||
scheduledSongIds.value.add(songId)
|
||
setSongScheduledFlag(songId, true)
|
||
|
||
const newOrder = [...localScheduledSongs.value]
|
||
newOrder.splice(dropIndex, 0, newSchedule)
|
||
newOrder.forEach((item, index) => {
|
||
item.sequence = index + 1
|
||
})
|
||
|
||
localScheduledSongs.value = newOrder
|
||
hasChanges.value = true
|
||
}
|
||
|
||
// 添加歌曲到排期(点击方式)
|
||
const addSongToSchedule = (song) => {
|
||
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song.id === song.id)
|
||
if (existingIndex !== -1) return
|
||
|
||
const newSchedule = {
|
||
id: Date.now(),
|
||
replayRequestId: song.replayRequestId || null,
|
||
song: song,
|
||
playDate: selectedDate.value,
|
||
sequence: localScheduledSongs.value.length + 1,
|
||
isNew: true,
|
||
isLocalOnly: true
|
||
}
|
||
|
||
scheduledSongIds.value.add(song.id)
|
||
setSongScheduledFlag(song.id, true)
|
||
localScheduledSongs.value.push(newSchedule)
|
||
hasChanges.value = true
|
||
|
||
if (navigator.vibrate) navigator.vibrate(50)
|
||
}
|
||
|
||
// 从排期移除歌曲(点击方式)
|
||
const removeSongFromSchedule = (schedule) => {
|
||
const index = localScheduledSongs.value.findIndex((s) => s.id === schedule.id)
|
||
|
||
if (index !== -1) {
|
||
const removed = localScheduledSongs.value.splice(index, 1)[0]
|
||
|
||
if (removed.song) {
|
||
scheduledSongIds.value.delete(removed.song.id)
|
||
setSongScheduledFlag(removed.song.id, false)
|
||
}
|
||
|
||
// 重新排序
|
||
localScheduledSongs.value.forEach((item, idx) => {
|
||
item.sequence = idx + 1
|
||
})
|
||
|
||
hasChanges.value = true
|
||
if (navigator.vibrate) navigator.vibrate(50)
|
||
}
|
||
}
|
||
|
||
// 处理拖回待排区域
|
||
const handleReturnToDraggable = async (event) => {
|
||
try {
|
||
const data = event.dataTransfer.getData('text/plain')
|
||
if (!data) return
|
||
|
||
const dragData = JSON.parse(data)
|
||
|
||
if (dragData.type === 'reorder-schedule') {
|
||
// 从播放列表拖回待排列表(移除)
|
||
const scheduleId = parseInt(dragData.scheduleId)
|
||
const index = localScheduledSongs.value.findIndex((s) => s.id === scheduleId)
|
||
|
||
if (index !== -1) {
|
||
const removed = localScheduledSongs.value.splice(index, 1)[0]
|
||
|
||
// 如果是本地新增的,直接移除;如果是已存在的,需要记录删除操作(这里简化为本地移除,保存时处理)
|
||
if (removed.song) {
|
||
scheduledSongIds.value.delete(removed.song.id)
|
||
// 同步清除歌曲列表中的已排期标记,否则已发布歌曲移出后会从待排列表中消失
|
||
setSongScheduledFlag(removed.song.id, false)
|
||
}
|
||
|
||
// 重新排序
|
||
localScheduledSongs.value.forEach((item, idx) => {
|
||
item.sequence = idx + 1
|
||
})
|
||
|
||
hasChanges.value = true
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('处理移除失败:', err)
|
||
}
|
||
|
||
isDraggableOver.value = false
|
||
}
|
||
|
||
// 标记全部已播放
|
||
const markAllAsPlayed = async () => {
|
||
if (localScheduledSongs.value.length === 0) return
|
||
|
||
confirmDialogTitle.value = locale.value.confirmations.markAllPlayedTitle
|
||
confirmDialogMessage.value = locale.value.confirmations.markAllPlayedMessage
|
||
confirmDialogType.value = 'info'
|
||
confirmDialogConfirmText.value = locale.value.confirmations.markAllPlayedConfirm
|
||
|
||
confirmAction.value = async () => {
|
||
loading.value = true
|
||
try {
|
||
const songIds = localScheduledSongs.value.map((s) => s.song.id)
|
||
|
||
await $fetch('/api/admin/songs/mark-played', {
|
||
method: 'POST',
|
||
body: { songIds },
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages.allMarkedPlayed, 'success')
|
||
}
|
||
|
||
// 重新加载数据
|
||
await loadData()
|
||
} catch (err) {
|
||
console.error('标记播放失败:', err)
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.errors.operationFailed, 'error')
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
showConfirmDialog.value = true
|
||
}
|
||
|
||
// 清空排期列表
|
||
const clearScheduleList = () => {
|
||
if (localScheduledSongs.value.length === 0) return
|
||
|
||
confirmDialogTitle.value = locale.value.confirmations.clearListTitle
|
||
confirmDialogMessage.value = locale.value.confirmations.clearListMessage
|
||
confirmDialogType.value = 'danger'
|
||
confirmDialogConfirmText.value = locale.value.confirmations.clearListConfirm
|
||
|
||
confirmAction.value = () => {
|
||
localScheduledSongs.value.forEach((schedule) => {
|
||
if (schedule.song) {
|
||
scheduledSongIds.value.delete(schedule.song.id)
|
||
}
|
||
})
|
||
localScheduledSongs.value = []
|
||
hasChanges.value = true
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages.playlistCleared, 'success')
|
||
}
|
||
}
|
||
|
||
showConfirmDialog.value = true
|
||
}
|
||
|
||
// 保存并发布
|
||
const saveSequence = async () => {
|
||
try {
|
||
await publishSchedule()
|
||
} catch (err) {
|
||
console.error('保存并发布失败:', err)
|
||
}
|
||
}
|
||
|
||
const openMoveDateDialog = () => {
|
||
if (hasChanges.value) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages.saveBeforeMove, 'warning')
|
||
}
|
||
return
|
||
}
|
||
|
||
moveTargetDate.value = selectedDate.value
|
||
showMoveDateDialog.value = true
|
||
}
|
||
|
||
const openCopyDateDialog = () => {
|
||
if (hasChanges.value) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.messages.saveBeforeCopy, 'warning')
|
||
}
|
||
return
|
||
}
|
||
|
||
const baseDate = selectedDate.value
|
||
const nextWeek = addDaysToString(baseDate, 7)
|
||
copyMode.value = 'single'
|
||
copyFromStart.value = baseDate
|
||
copyFromEnd.value = baseDate
|
||
copyToStart.value = nextWeek
|
||
copyToEnd.value = nextWeek
|
||
copySingleTargetDate.value = nextWeek
|
||
showCopyDateDialog.value = true
|
||
}
|
||
|
||
const switchCopyMode = (mode) => {
|
||
copyMode.value = mode
|
||
}
|
||
|
||
const confirmMoveDate = async () => {
|
||
const targetDate = moveTargetDate.value.trim()
|
||
|
||
if (!parseDateValue(targetDate)) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(
|
||
callLocale('errors.invalidTargetDate', '目标日期无效,请使用 YYYY-MM-DD 格式并确保日期有效'),
|
||
'error'
|
||
)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (targetDate === selectedDate.value) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.errors.sameTargetDate, 'warning')
|
||
}
|
||
return
|
||
}
|
||
|
||
const sourceDate = selectedDate.value
|
||
const sourceSchedules = [...publicSchedules.value, ...drafts.value].filter((schedule) => {
|
||
if (!schedule.playDate) return false
|
||
return getScheduleDateValue(schedule.playDate) === sourceDate
|
||
})
|
||
|
||
if (sourceSchedules.length === 0) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.errors.noMovableSongs, 'warning')
|
||
}
|
||
return
|
||
}
|
||
|
||
confirmDialogTitle.value = locale.value.moveDateTitle
|
||
confirmDialogMessage.value = callLocale(
|
||
'confirmations.moveDateMessage',
|
||
`确定将 ${sourceDate} 的所有 ${sourceSchedules.length} 首歌曲迁移到 ${targetDate} 吗?歌曲顺序与内容将保持不变。`,
|
||
sourceDate,
|
||
sourceSchedules.length,
|
||
targetDate
|
||
)
|
||
confirmDialogType.value = 'warning'
|
||
confirmDialogConfirmText.value = locale.value.confirmations.moveDateConfirm
|
||
showMoveDateDialog.value = false
|
||
|
||
confirmAction.value = async () => {
|
||
loading.value = true
|
||
try {
|
||
const result = await $fetch('/api/admin/schedule/move-date', {
|
||
method: 'POST',
|
||
body: {
|
||
fromDate: sourceDate,
|
||
toDate: targetDate
|
||
},
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
await loadData()
|
||
updateLocalScheduledSongs()
|
||
|
||
if (window.$showNotification) {
|
||
window.$showNotification(
|
||
result?.movedCount > 0
|
||
? callLocale(
|
||
'messages.moveDateSuccess',
|
||
`已迁移 ${result.movedCount} 首歌曲到 ${targetDate}`,
|
||
result.movedCount,
|
||
targetDate
|
||
)
|
||
: locale.value.errors.noMovableSongs,
|
||
result?.movedCount > 0 ? 'success' : 'warning'
|
||
)
|
||
}
|
||
} catch (error) {
|
||
console.error('迁移排期日期失败:', error)
|
||
if (window.$showNotification) {
|
||
const backendMessage =
|
||
getThrownMessage(error) || formatLocaleValue(locale.value?.unknown) || '未知错误'
|
||
window.$showNotification(
|
||
callLocale('errors.moveDateFailed', `迁移失败: ${backendMessage}`, backendMessage),
|
||
'error'
|
||
)
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
showConfirmDialog.value = true
|
||
}
|
||
|
||
const confirmCopyDate = async () => {
|
||
if (copyMode.value === 'single') {
|
||
const sourceDate = copyFromStart.value.trim()
|
||
const targetDate = copySingleTargetDate.value.trim()
|
||
|
||
if (!parseDateValue(sourceDate) || !parseDateValue(targetDate)) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(
|
||
callLocale('errors.invalidTargetDate', '日期无效,请使用 YYYY-MM-DD 格式并确保日期有效'),
|
||
'error'
|
||
)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (sourceDate === targetDate) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.errors.sameTargetDate, 'warning')
|
||
}
|
||
return
|
||
}
|
||
|
||
// API 会检查目标日期是否已有排期,此处不需要重复检测
|
||
|
||
// 检查源日期是否有可复制的排期
|
||
const sourceSchedules = [...publicSchedules.value, ...drafts.value].filter((schedule) => {
|
||
if (!schedule.playDate) return false
|
||
return getScheduleDateValue(schedule.playDate) === sourceDate
|
||
})
|
||
|
||
// 检查目标日期是否有草稿
|
||
const targetDateDrafts = drafts.value.filter((d) => d.playDate && getScheduleDateValue(d.playDate) === targetDate)
|
||
if (targetDateDrafts.length > 0) {
|
||
confirmDialogTitle.value = locale.value.copyDateTitle
|
||
confirmDialogMessage.value = callLocale('confirmations.copyDateOverwriteDraftConfirm', '目标日期有草稿将被覆盖,确定继续?', targetDateDrafts.length)
|
||
confirmDialogType.value = 'warning'
|
||
confirmDialogConfirmText.value = locale.value.confirmations.copyDateSingleConfirm
|
||
showCopyDateDialog.value = false
|
||
confirmAction.value = async () => {
|
||
loading.value = true
|
||
try {
|
||
let totalCopied = 0
|
||
if (sourceSchedules.length > 0) {
|
||
const result = await $fetch('/api/admin/schedule/copy', {
|
||
method: 'POST',
|
||
body: { fromDate: sourceDate, toDate: targetDate, overwriteDrafts: true },
|
||
...auth.getAuthConfig()
|
||
})
|
||
totalCopied = result?.copiedCount || 0
|
||
}
|
||
await loadData()
|
||
updateLocalScheduledSongs()
|
||
if (window.$showNotification) {
|
||
window.$showNotification(
|
||
totalCopied > 0
|
||
? callLocale('messages.copyDateSuccess', `已复制 ${totalCopied} 首歌曲到 ${targetDate}`, totalCopied, targetDate, targetDate)
|
||
: locale.value.errors.noCopyableSongs,
|
||
totalCopied > 0 ? 'success' : 'warning'
|
||
)
|
||
}
|
||
} catch (error) {
|
||
console.error('复制排期日期失败:', error)
|
||
if (window.$showNotification) {
|
||
const backendMessage = getThrownMessage(error) || formatLocaleValue(locale.value?.unknown) || '未知错误'
|
||
window.$showNotification(callLocale('errors.copyDateFailed', `复制失败: ${backendMessage}`, backendMessage), 'error')
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
showConfirmDialog.value = true
|
||
return
|
||
}
|
||
|
||
confirmDialogTitle.value = locale.value.copyDateTitle
|
||
confirmDialogMessage.value = callLocale(
|
||
'confirmations.copyDateSingleMessage',
|
||
`确定将 ${sourceDate} 的排期复制到 ${targetDate} 吗?`,
|
||
sourceDate,
|
||
targetDate
|
||
)
|
||
confirmDialogType.value = 'warning'
|
||
confirmDialogConfirmText.value = locale.value.confirmations.copyDateConfirm
|
||
showCopyDateDialog.value = false
|
||
|
||
confirmAction.value = async () => {
|
||
loading.value = true
|
||
try {
|
||
let totalCopied = 0
|
||
|
||
if (sourceSchedules.length > 0) {
|
||
// 源日期有排期,调用API复制
|
||
const result = await $fetch('/api/admin/schedule/copy', {
|
||
method: 'POST',
|
||
body: { fromDate: sourceDate, toDate: targetDate, overwriteDrafts: true },
|
||
...auth.getAuthConfig()
|
||
})
|
||
totalCopied = result?.copiedCount || 0
|
||
}
|
||
|
||
await loadData()
|
||
updateLocalScheduledSongs()
|
||
|
||
if (window.$showNotification) {
|
||
window.$showNotification(
|
||
totalCopied > 0
|
||
? callLocale(
|
||
'messages.copyDateSuccess',
|
||
`已复制 ${totalCopied} 首歌曲到 ${targetDate}`,
|
||
totalCopied,
|
||
targetDate,
|
||
targetDate
|
||
)
|
||
: locale.value.errors.noCopyableSongs,
|
||
totalCopied > 0 ? 'success' : 'warning'
|
||
)
|
||
}
|
||
} catch (error) {
|
||
console.error('复制排期日期失败:', error)
|
||
if (window.$showNotification) {
|
||
const backendMessage =
|
||
getThrownMessage(error) || formatLocaleValue(locale.value?.unknown) || '未知错误'
|
||
window.$showNotification(
|
||
callLocale('errors.copyDateFailed', `复制失败: ${backendMessage}`, backendMessage),
|
||
'error'
|
||
)
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
showConfirmDialog.value = true
|
||
return
|
||
}
|
||
|
||
// --- 周期模式(原有逻辑) ---
|
||
const fromStart = copyFromStart.value.trim()
|
||
const fromEnd = copyFromEnd.value.trim()
|
||
const toStart = copyToStart.value.trim()
|
||
const toEnd = copyToEnd.value.trim()
|
||
|
||
if (!parseDateValue(fromStart) || !parseDateValue(fromEnd) || !parseDateValue(toStart) || !parseDateValue(toEnd)) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(
|
||
callLocale('errors.invalidTargetDate', '日期无效,请使用 YYYY-MM-DD 格式并确保日期有效'),
|
||
'error'
|
||
)
|
||
}
|
||
return
|
||
}
|
||
|
||
const sourceSpan = getDaysBetween(fromStart, fromEnd)
|
||
const targetSpan = getDaysBetween(toStart, toEnd)
|
||
|
||
if (sourceSpan < 0 || targetSpan < 0) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.errors.invalidDateRange, 'error')
|
||
}
|
||
return
|
||
}
|
||
|
||
const sourceDays = sourceSpan + 1
|
||
const targetDays = targetSpan + 1
|
||
|
||
// 检查源区间与目标区间是否有交集(整体范围检测,防止级联写入)
|
||
if (toStart <= fromEnd && fromStart <= toEnd) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification(locale.value.errors.targetDateConflicts, 'warning')
|
||
}
|
||
return
|
||
}
|
||
|
||
// 检查目标日期是否有草稿
|
||
const draftTargetDates = []
|
||
for (let i = 0; i < targetDays; i++) {
|
||
const tgtDate = addDaysToString(toStart, i)
|
||
const tgtDrafts = drafts.value.filter((d) => d.playDate && getScheduleDateValue(d.playDate) === tgtDate)
|
||
if (tgtDrafts.length > 0) {
|
||
draftTargetDates.push(tgtDate)
|
||
}
|
||
}
|
||
|
||
const hasDraftOverlap = draftTargetDates.length > 0
|
||
confirmDialogTitle.value = locale.value.copyDateTitle
|
||
confirmDialogMessage.value = hasDraftOverlap
|
||
? callLocale(
|
||
'confirmations.copyDateOverwriteMessage',
|
||
`确定将 ${fromStart} 至 ${fromEnd} 共 ${sourceDays} 天的排期复制到 ${toStart} 至 ${toEnd} 共 ${targetDays} 天吗?${draftTargetDates.length} 个目标日期有草稿将被覆盖。源排期将循环复用至填满目标区间。`,
|
||
fromStart,
|
||
fromEnd,
|
||
sourceDays,
|
||
toStart,
|
||
toEnd,
|
||
targetDays,
|
||
draftTargetDates.length
|
||
)
|
||
: callLocale(
|
||
'confirmations.copyDateMessage',
|
||
`确定将 ${fromStart} 至 ${fromEnd} 共 ${sourceDays} 天的排期复制到 ${toStart} 至 ${toEnd} 共 ${targetDays} 天吗?源排期将循环复用至填满目标区间。`,
|
||
fromStart,
|
||
fromEnd,
|
||
sourceDays,
|
||
toStart,
|
||
toEnd,
|
||
targetDays
|
||
)
|
||
confirmDialogType.value = hasDraftOverlap ? 'warning' : 'warning'
|
||
confirmDialogConfirmText.value = locale.value.confirmations.copyDateConfirm
|
||
showCopyDateDialog.value = false
|
||
|
||
confirmAction.value = async () => {
|
||
loading.value = true
|
||
try {
|
||
let totalCopied = 0
|
||
let succeededScheduleIds = []
|
||
let replacedDrafts = []
|
||
|
||
for (let i = 0; i < targetDays; i++) {
|
||
const srcDay = i % sourceDays
|
||
const srcDate = addDaysToString(fromStart, srcDay)
|
||
const tgtDate = addDaysToString(toStart, i)
|
||
const overwriteDrafts = draftTargetDates.includes(tgtDate)
|
||
|
||
const result = await $fetch('/api/admin/schedule/copy', {
|
||
method: 'POST',
|
||
body: {
|
||
fromDate: srcDate,
|
||
toDate: tgtDate,
|
||
overwriteDrafts
|
||
},
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
totalCopied += result?.copiedCount || 0
|
||
succeededScheduleIds.push(...(result?.createdScheduleIds || []))
|
||
replacedDrafts.push(...(result?.replacedDrafts || []))
|
||
}
|
||
|
||
await loadData()
|
||
updateLocalScheduledSongs()
|
||
|
||
if (window.$showNotification) {
|
||
window.$showNotification(
|
||
totalCopied > 0
|
||
? callLocale(
|
||
'messages.copyDateSuccess',
|
||
`已逐日复制 ${totalCopied} 首歌曲至 ${toStart} ~ ${toEnd}`,
|
||
totalCopied,
|
||
toStart,
|
||
toEnd
|
||
)
|
||
: locale.value.errors.noCopyableSongs,
|
||
totalCopied > 0 ? 'success' : 'warning'
|
||
)
|
||
}
|
||
} catch (error) {
|
||
// 只回滚本批创建的排期,并恢复本批覆盖的草稿,避免误删并发变更
|
||
if (succeededScheduleIds.length > 0 || replacedDrafts.length > 0) {
|
||
try {
|
||
await $fetch('/api/admin/schedule/remove-all-date', {
|
||
method: 'POST',
|
||
body: { scheduleIds: succeededScheduleIds, restoreSchedules: replacedDrafts },
|
||
...auth.getAuthConfig()
|
||
})
|
||
} catch (rollbackError) {
|
||
console.error('回滚已复制的排期失败:', rollbackError)
|
||
}
|
||
}
|
||
console.error('复制排期日期失败:', error)
|
||
if (window.$showNotification) {
|
||
const backendMessage =
|
||
getThrownMessage(error) || formatLocaleValue(locale.value?.unknown) || '未知错误'
|
||
window.$showNotification(
|
||
callLocale('errors.copyDateFailed', `复制失败: ${backendMessage}`, backendMessage),
|
||
'error'
|
||
)
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
showConfirmDialog.value = true
|
||
}
|
||
|
||
// 草稿相关方法
|
||
|
||
// 加载草稿列表(使用新的综合API)
|
||
const loadDrafts = async () => {
|
||
try {
|
||
const response = await $fetch('/api/admin/schedule/full', {
|
||
...auth.getAuthConfig(),
|
||
query: {
|
||
includeDrafts: 'only' // 只获取草稿
|
||
}
|
||
})
|
||
|
||
drafts.value = response.data?.schedules || []
|
||
console.log('加载草稿列表:', drafts.value.length)
|
||
} catch (error) {
|
||
console.error('加载草稿列表失败:', error)
|
||
// 如果加载失败,设置为空数组避免错误
|
||
drafts.value = []
|
||
}
|
||
}
|
||
|
||
// 刷新草稿列表
|
||
const refreshDrafts = async () => {
|
||
await loadDrafts()
|
||
updateLocalScheduledSongs() // 更新播放顺序列表
|
||
}
|
||
|
||
// 保存草稿(无需确认)
|
||
// 流程:先写入全部草稿,全部成功后再删除旧排期,避免中间失败导致数据丢失
|
||
const saveDraft = async () => {
|
||
loading.value = true
|
||
|
||
try {
|
||
// 收集当天指定播出时段的所有现有排期和草稿 ID
|
||
const existingScheduleIds = [...publicSchedules.value, ...drafts.value]
|
||
.filter((s) => {
|
||
if (!s.playDate) return false
|
||
const scheduleDateStr = getScheduleDateValue(s.playDate)
|
||
const isTargetDate = scheduleDateStr === selectedDate.value
|
||
if (selectedPlayTime.value) {
|
||
return isTargetDate && s.playTimeId === parseInt(selectedPlayTime.value)
|
||
}
|
||
return isTargetDate
|
||
})
|
||
.map((s) => s.id)
|
||
|
||
// 先写入全部草稿,全部成功后再删除旧排期
|
||
const newDraftIds = []
|
||
for (let i = 0; i < localScheduledSongs.value.length; i++) {
|
||
const song = localScheduledSongs.value[i]
|
||
|
||
try {
|
||
const created = await $fetch('/api/admin/schedule/draft', {
|
||
method: 'POST',
|
||
body: {
|
||
songId: song.song.id,
|
||
playDate: selectedDate.value,
|
||
sequence: i + 1,
|
||
playTimeId: selectedPlayTime.value ? parseInt(selectedPlayTime.value) : null,
|
||
replayRequestId: song.replayRequestId || song.song?.replayRequestId || null
|
||
},
|
||
...auth.getAuthConfig()
|
||
})
|
||
if (created?.id) {
|
||
newDraftIds.push(created.id)
|
||
}
|
||
} catch (error) {
|
||
console.error(`创建草稿排期失败 (歌曲: ${song.song.title}):`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 全部写入成功后,删除旧排期和草稿
|
||
try {
|
||
for (const scheduleId of existingScheduleIds) {
|
||
await $fetch(`/api/admin/schedule/remove`, {
|
||
method: 'POST',
|
||
body: { scheduleId },
|
||
...auth.getAuthConfig()
|
||
})
|
||
}
|
||
} catch (deleteError) {
|
||
console.error('删除旧排期失败:', deleteError)
|
||
// 删除失败时回滚新建草稿
|
||
for (const draftId of newDraftIds) {
|
||
try {
|
||
await $fetch('/api/admin/schedule/remove', {
|
||
method: 'POST',
|
||
body: { scheduleId: draftId },
|
||
...auth.getAuthConfig()
|
||
})
|
||
} catch (rollbackErr) {
|
||
console.error('回滚新建草稿失败:', rollbackErr)
|
||
}
|
||
}
|
||
throw deleteError
|
||
}
|
||
|
||
hasChanges.value = false
|
||
await loadData() // 重新加载数据
|
||
|
||
// 确保草稿显示在播放顺序中
|
||
updateLocalScheduledSongs()
|
||
|
||
if (window.$showNotification) {
|
||
if (localScheduledSongs.value.length > 0) {
|
||
window.$showNotification(locale.value.messages.draftSaved, 'success')
|
||
} else {
|
||
window.$showNotification(locale.value.messages.allDraftsDeleted, 'success')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('保存草稿失败:', error)
|
||
if (window.$showNotification) {
|
||
const message = getThrownMessage(error) || '未知错误'
|
||
window.$showNotification(
|
||
callLocale('errors.saveDraftFailed', `保存草稿失败: ${message}`, message),
|
||
'error'
|
||
)
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 发布排期(需要确认)
|
||
const publishSchedule = async () => {
|
||
try {
|
||
// 如果列表为空,提示删除排期
|
||
if (localScheduledSongs.value.length === 0) {
|
||
confirmDialogTitle.value = locale.value.confirmations.deleteScheduleTitle
|
||
confirmDialogMessage.value = locale.value.confirmations.deleteScheduleMessage
|
||
confirmDialogType.value = 'danger'
|
||
confirmDialogConfirmText.value = locale.value.confirmations.deleteScheduleConfirm
|
||
} else {
|
||
confirmDialogTitle.value = locale.value.confirmations.publishScheduleTitle
|
||
confirmDialogMessage.value = locale.value.confirmations.publishScheduleMessage
|
||
confirmDialogType.value = 'warning'
|
||
confirmDialogConfirmText.value = locale.value.publishSchedule
|
||
}
|
||
|
||
confirmAction.value = async () => {
|
||
await publishScheduleConfirmed()
|
||
}
|
||
showConfirmDialog.value = true
|
||
} catch (error) {
|
||
console.error('发布排期失败:', error)
|
||
}
|
||
}
|
||
|
||
// 确认发布排期
|
||
const publishScheduleConfirmed = async () => {
|
||
loading.value = true
|
||
|
||
try {
|
||
// 构建发布数据,携带拖拽时显式选择的重播申请绑定
|
||
const songsToPublish = localScheduledSongs.value.map((item, index) => ({
|
||
songId: item.song.id,
|
||
sequence: index + 1,
|
||
replayRequestId: item.replayRequestId || item.song?.replayRequestId || null
|
||
}))
|
||
|
||
// 调用批量发布API
|
||
await $fetch('/api/admin/schedule/bulk-publish', {
|
||
method: 'POST',
|
||
body: {
|
||
playDate: selectedDate.value,
|
||
playTimeId: selectedPlayTime.value ? parseInt(selectedPlayTime.value) : null,
|
||
songs: songsToPublish
|
||
},
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
hasChanges.value = false
|
||
await loadData() // 重新加载数据
|
||
|
||
// 确保界面更新
|
||
updateLocalScheduledSongs()
|
||
|
||
if (window.$showNotification) {
|
||
if (songsToPublish.length === 0) {
|
||
window.$showNotification(locale.value.messages.scheduleDeleted, 'success')
|
||
} else {
|
||
window.$showNotification(locale.value.messages.schedulePublished, 'success')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('发布排期失败:', error)
|
||
if (window.$showNotification) {
|
||
const message = getThrownMessage(error) || '未知错误'
|
||
window.$showNotification(
|
||
callLocale('errors.publishScheduleFailed', `发布排期失败: ${message}`, message),
|
||
'error'
|
||
)
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 发布单个草稿(需要确认)
|
||
const publishSingleDraft = async (draft) => {
|
||
try {
|
||
confirmDialogTitle.value = locale.value.confirmations.publishDraftTitle
|
||
confirmDialogMessage.value = callLocale(
|
||
'confirmations.publishDraftMessage',
|
||
`确定要发布草稿《${draft.song.title}》吗?发布后将立即公示并发送通知。`,
|
||
draft.song.title
|
||
)
|
||
confirmDialogType.value = 'warning'
|
||
confirmDialogConfirmText.value = locale.value.publish
|
||
confirmAction.value = async () => {
|
||
await publishSingleDraftConfirmed(draft)
|
||
}
|
||
showConfirmDialog.value = true
|
||
} catch (error) {
|
||
console.error('发布单个草稿失败:', error)
|
||
}
|
||
}
|
||
|
||
// 确认发布单个草稿
|
||
const publishSingleDraftConfirmed = async (draft) => {
|
||
loading.value = true
|
||
|
||
try {
|
||
await $fetch('/api/admin/schedule/publish', {
|
||
method: 'POST',
|
||
body: { scheduleId: draft.id },
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
await loadData() // 重新加载数据
|
||
|
||
// 确保界面更新
|
||
updateLocalScheduledSongs()
|
||
|
||
if (window.$showNotification) {
|
||
window.$showNotification(
|
||
callLocale(
|
||
'messages.draftPublished',
|
||
`草稿《${draft.song.title}》发布成功,通知已发送!`,
|
||
draft.song.title
|
||
),
|
||
'success'
|
||
)
|
||
}
|
||
} catch (error) {
|
||
console.error('发布单个草稿失败:', error)
|
||
if (window.$showNotification) {
|
||
const message = getThrownMessage(error) || '未知错误'
|
||
window.$showNotification(
|
||
callLocale('errors.publishDraftFailed', `发布草稿失败: ${message}`, message),
|
||
'error'
|
||
)
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 触摸拖拽方法
|
||
const handleTouchStart = (event, item, type) => {
|
||
// 在移动端,如果是待排歌曲列表(type='song'),禁用拖拽逻辑,只允许通过加号按钮添加
|
||
if (window.innerWidth < 1024 && type === 'song') {
|
||
return
|
||
}
|
||
|
||
// 在所有设备上启用触摸拖拽,但桌面端优先使用原生拖拽
|
||
|
||
const touch = event.touches[0]
|
||
touchStartPos.value = { x: touch.clientX, y: touch.clientY }
|
||
touchCurrentPos.value = { x: touch.clientX, y: touch.clientY }
|
||
touchStartTime.value = Date.now()
|
||
touchDragData.value = { item, type }
|
||
|
||
// 重置状态
|
||
isDragging.value = false
|
||
isLongPressing.value = false
|
||
|
||
// 清除之前的长按定时器
|
||
if (longPressTimer.value) {
|
||
clearTimeout(longPressTimer.value)
|
||
}
|
||
|
||
// 在移动端使用较短的长按时间,桌面端使用较长时间
|
||
const longPressDelay = window.innerWidth <= 768 ? 300 : TOUCH_CONFIG.LONG_PRESS_DURATION
|
||
|
||
// 设置长按识别定时器
|
||
longPressTimer.value = setTimeout(() => {
|
||
if (!isDragging.value && touchDragData.value) {
|
||
isLongPressing.value = true
|
||
|
||
// 触发震动反馈(如果设备支持)
|
||
if (navigator.vibrate) {
|
||
navigator.vibrate(TOUCH_CONFIG.VIBRATION_DURATION)
|
||
}
|
||
|
||
// 添加长按视觉反馈
|
||
const target = event.target.closest('.draggable-song, .scheduled-song')
|
||
if (target) {
|
||
target.classList.add('opacity-75', 'scale-95')
|
||
dragElement.value = target
|
||
}
|
||
}
|
||
}, longPressDelay)
|
||
|
||
// 只在必要时防止默认行为
|
||
// event.preventDefault()
|
||
}
|
||
|
||
const handleTouchMove = (event) => {
|
||
if (!touchDragData.value) return
|
||
|
||
const touch = event.touches[0]
|
||
touchCurrentPos.value = { x: touch.clientX, y: touch.clientY }
|
||
|
||
const deltaX = Math.abs(touch.clientX - touchStartPos.value.x)
|
||
const deltaY = Math.abs(touch.clientY - touchStartPos.value.y)
|
||
const totalDelta = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||
|
||
// 如果移动距离较小,可能是滚动操作,不触发拖拽
|
||
if (totalDelta < TOUCH_CONFIG.SCROLL_THRESHOLD) {
|
||
return
|
||
}
|
||
|
||
// 清除长按定时器(用户开始移动)
|
||
if (longPressTimer.value) {
|
||
clearTimeout(longPressTimer.value)
|
||
longPressTimer.value = null
|
||
}
|
||
|
||
// 在移动端使用较小的拖拽阈值,桌面端需要长按
|
||
const dragThreshold = window.innerWidth <= 768 ? 10 : TOUCH_CONFIG.DRAG_THRESHOLD
|
||
|
||
// 只有在长按识别后或移动距离超过阈值时才开始拖拽
|
||
if (!isDragging.value && (isLongPressing.value || totalDelta > dragThreshold)) {
|
||
isDragging.value = true
|
||
|
||
// 创建拖拽元素
|
||
const target = event.target.closest('.draggable-song, .scheduled-song')
|
||
if (target) {
|
||
target.classList.remove('scale-95')
|
||
target.classList.add('opacity-50')
|
||
dragElement.value = target
|
||
|
||
// 触发拖拽开始震动
|
||
if (navigator.vibrate) {
|
||
navigator.vibrate(TOUCH_CONFIG.VIBRATION_DURATION)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新拖拽位置指示
|
||
if (isDragging.value) {
|
||
updateDragPosition(touch.clientX, touch.clientY)
|
||
event.preventDefault()
|
||
}
|
||
}
|
||
|
||
// 更新拖拽位置指示
|
||
const updateDragPosition = (x, y) => {
|
||
const elementBelow = document.elementFromPoint(x, y)
|
||
if (!elementBelow) return
|
||
|
||
// 清除之前的高亮
|
||
document.querySelectorAll('.border-primary').forEach((el) => {
|
||
// 仅移除通过拖拽添加的高亮,避免移除原本的样式
|
||
if (el.dataset.dragHighlight) {
|
||
el.classList.remove('border-primary', 'bg-primary-10')
|
||
delete el.dataset.dragHighlight
|
||
}
|
||
})
|
||
|
||
// 高亮当前目标区域
|
||
const sequenceList = elementBelow.closest('.sequence-list')
|
||
const scheduledSong = elementBelow.closest('.scheduled-song')
|
||
const draggableSongs = elementBelow.closest('.draggable-songs')
|
||
|
||
// 根据拖拽类型高亮不同的目标区域
|
||
if (touchDragData.value?.type === 'song') {
|
||
// 拖拽待排歌曲时,高亮播放列表区域
|
||
if (sequenceList) {
|
||
sequenceList.classList.add('border-primary', 'bg-primary-10')
|
||
sequenceList.dataset.dragHighlight = 'true'
|
||
} else if (scheduledSong) {
|
||
scheduledSong.classList.add('border-primary', 'bg-primary-10')
|
||
scheduledSong.dataset.dragHighlight = 'true'
|
||
}
|
||
} else if (touchDragData.value?.type === 'schedule') {
|
||
// 拖拽已排歌曲时,高亮待排区域或其他已排歌曲
|
||
if (draggableSongs) {
|
||
draggableSongs.classList.add('border-primary', 'bg-primary-10')
|
||
draggableSongs.dataset.dragHighlight = 'true'
|
||
} else if (scheduledSong) {
|
||
scheduledSong.classList.add('border-primary', 'bg-primary-10')
|
||
scheduledSong.dataset.dragHighlight = 'true'
|
||
}
|
||
}
|
||
}
|
||
|
||
// 清除拖拽位置指示
|
||
const clearDragPosition = () => {
|
||
document.querySelectorAll('.border-primary').forEach((el) => {
|
||
if (el.dataset.dragHighlight) {
|
||
el.classList.remove('border-primary', 'bg-primary-10')
|
||
delete el.dataset.dragHighlight
|
||
}
|
||
})
|
||
}
|
||
|
||
// 清理触控拖拽状态
|
||
const cleanupTouchDrag = () => {
|
||
if (dragElement.value) {
|
||
dragElement.value.classList.remove('opacity-50', 'opacity-75', 'scale-95')
|
||
dragElement.value = null
|
||
}
|
||
|
||
// 重置状态
|
||
isDragging.value = false
|
||
isLongPressing.value = false
|
||
touchDragData.value = null
|
||
dragOverIndex.value = -1
|
||
isSequenceOver.value = false
|
||
isDraggableOver.value = false
|
||
|
||
// 清除位置指示
|
||
clearDragPosition()
|
||
}
|
||
|
||
const handleTouchEnd = (event) => {
|
||
if (!touchDragData.value) return
|
||
|
||
// 清除长按定时器
|
||
if (longPressTimer.value) {
|
||
clearTimeout(longPressTimer.value)
|
||
longPressTimer.value = null
|
||
}
|
||
|
||
if (isDragging.value) {
|
||
const touch = event.changedTouches[0]
|
||
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY)
|
||
|
||
if (elementBelow) {
|
||
// 检查是否拖拽到序列列表
|
||
const sequenceList = elementBelow.closest('.sequence-list')
|
||
const scheduledSong = elementBelow.closest('.scheduled-song')
|
||
const draggableSongs = elementBelow.closest('.draggable-songs')
|
||
|
||
if (touchDragData.value.type === 'song' && (sequenceList || scheduledSong)) {
|
||
// 从左侧拖拽到右侧
|
||
handleTouchDropToSequence(scheduledSong)
|
||
// 成功拖拽震动反馈
|
||
if (navigator.vibrate) {
|
||
navigator.vibrate([30, 50, 30])
|
||
}
|
||
} else if (touchDragData.value.type === 'schedule' && scheduledSong) {
|
||
// 在右侧重新排序
|
||
handleTouchReorder(scheduledSong)
|
||
// 成功拖拽震动反馈
|
||
if (navigator.vibrate) {
|
||
navigator.vibrate([30, 50, 30])
|
||
}
|
||
} else if (touchDragData.value.type === 'schedule' && draggableSongs) {
|
||
// 从右侧拖拽回左侧
|
||
handleTouchReturnToDraggable()
|
||
// 成功拖拽震动反馈
|
||
if (navigator.vibrate) {
|
||
navigator.vibrate([30, 50, 30])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 清理拖拽状态
|
||
cleanupTouchDrag()
|
||
}
|
||
|
||
const handleTouchDropToSequence = async (targetElement) => {
|
||
const song = touchDragData.value.item
|
||
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song.id === song.id)
|
||
if (existingIndex !== -1) return
|
||
|
||
let insertIndex = localScheduledSongs.value.length
|
||
|
||
if (targetElement) {
|
||
const scheduleId = parseInt(targetElement.dataset.scheduleId)
|
||
const targetIndex = localScheduledSongs.value.findIndex((s) => s.id === scheduleId)
|
||
if (targetIndex !== -1) {
|
||
insertIndex = targetIndex
|
||
}
|
||
}
|
||
|
||
// 直接添加到本地列表,不发送请求
|
||
const newSchedule = {
|
||
id: Date.now(),
|
||
replayRequestId: song.replayRequestId || null,
|
||
song: song,
|
||
playDate: selectedDate.value,
|
||
sequence: insertIndex + 1,
|
||
isNew: true,
|
||
isLocalOnly: true
|
||
}
|
||
|
||
scheduledSongIds.value.add(song.id)
|
||
setSongScheduledFlag(song.id, true)
|
||
localScheduledSongs.value.splice(insertIndex, 0, newSchedule)
|
||
|
||
// 更新序列号
|
||
localScheduledSongs.value.forEach((item, idx) => {
|
||
item.sequence = idx + 1
|
||
})
|
||
|
||
hasChanges.value = true
|
||
}
|
||
|
||
const handleTouchReorder = async (targetElement) => {
|
||
const draggedSchedule = touchDragData.value.item
|
||
const scheduleId = parseInt(targetElement.dataset.scheduleId)
|
||
const draggedIndex = localScheduledSongs.value.findIndex((s) => s.id === draggedSchedule.id)
|
||
const dropIndex = localScheduledSongs.value.findIndex((s) => s.id === scheduleId)
|
||
|
||
if (draggedIndex === -1 || dropIndex === -1 || draggedIndex === dropIndex) return
|
||
|
||
const newOrder = [...localScheduledSongs.value]
|
||
const [draggedItem] = newOrder.splice(draggedIndex, 1)
|
||
newOrder.splice(dropIndex, 0, draggedItem)
|
||
|
||
newOrder.forEach((item, index) => {
|
||
item.sequence = index + 1
|
||
})
|
||
|
||
localScheduledSongs.value = newOrder
|
||
hasChanges.value = true
|
||
}
|
||
|
||
const handleTouchReturnToDraggable = async () => {
|
||
const draggedSchedule = touchDragData.value.item
|
||
const index = localScheduledSongs.value.findIndex((s) => s.id === draggedSchedule.id)
|
||
|
||
if (index !== -1) {
|
||
const removed = localScheduledSongs.value.splice(index, 1)[0]
|
||
|
||
if (removed.song) {
|
||
scheduledSongIds.value.delete(removed.song.id)
|
||
setSongScheduledFlag(removed.song.id, false)
|
||
}
|
||
|
||
// 重新排序
|
||
localScheduledSongs.value.forEach((item, idx) => {
|
||
item.sequence = idx + 1
|
||
})
|
||
|
||
hasChanges.value = true
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 隐藏滚动条但保留功能 */
|
||
.scrollbar-hide::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
.scrollbar-hide {
|
||
-ms-overflow-style: none;
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
/* 列表过渡动画 */
|
||
.schedule-list-move,
|
||
.schedule-list-enter-active,
|
||
.schedule-list-leave-active {
|
||
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||
}
|
||
|
||
.schedule-list-enter-from,
|
||
.schedule-list-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(20px);
|
||
}
|
||
|
||
.schedule-list-leave-active {
|
||
position: absolute;
|
||
width: 100%;
|
||
}
|
||
</style>
|