3019 lines
103 KiB
Vue
3019 lines
103 KiB
Vue
<template>
|
||
<div class="space-y-5 pb-24 md:pb-6">
|
||
<!-- 日期与排期配置区 -->
|
||
<div class="space-y-3">
|
||
<AdminDatePicker
|
||
:modelValue="selectedDate"
|
||
@update:modelValue="handleDateSelect"
|
||
/>
|
||
|
||
<!-- 播出时段标签组 + 自动排期策略 -->
|
||
<div
|
||
class="space-y-3 bg-zinc-50/30 border border-zinc-200 rounded-xl p-3 md:p-4"
|
||
>
|
||
<!-- 播出时段:标签按钮组(直接点击切换) -->
|
||
<div v-if="playTimeEnabled && playTimes.length > 0" class="space-y-2">
|
||
<div class="flex items-center justify-between px-1">
|
||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">播出时段</span>
|
||
<span class="text-[10px] font-bold text-zinc-500">
|
||
{{ selectedPlayTime ? '已按时段过滤' : '显示全天排期' }}
|
||
</span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
:class="[
|
||
'px-4 py-2 rounded-xl text-xs font-black border transition-all whitespace-nowrap',
|
||
selectedPlayTime === ''
|
||
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
|
||
: 'bg-white border-zinc-200 text-zinc-400 hover:text-zinc-700 hover:border-zinc-200'
|
||
]"
|
||
@click="handlePlayTimeSelect('')"
|
||
>
|
||
<span class="flex items-center gap-1.5">
|
||
<Layers :size="12" />
|
||
全部时段 (全天)
|
||
</span>
|
||
</button>
|
||
<button
|
||
v-for="pt in playTimes"
|
||
:key="pt.id"
|
||
:disabled="!pt.enabled"
|
||
:class="[
|
||
'px-4 py-2 rounded-xl text-xs font-black border transition-all whitespace-nowrap',
|
||
selectedPlayTime === String(pt.id)
|
||
? pt.enabled
|
||
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
|
||
: 'bg-zinc-200 border-zinc-300 text-zinc-600'
|
||
: pt.enabled
|
||
? 'bg-white border-zinc-200 text-zinc-400 hover:text-zinc-700 hover:border-zinc-200'
|
||
: 'bg-white/80 border-zinc-200 text-zinc-700 cursor-not-allowed'
|
||
]"
|
||
@click="pt.enabled && handlePlayTimeSelect(String(pt.id))"
|
||
>
|
||
<span class="flex items-center gap-1.5">
|
||
<Clock :size="12" />
|
||
<span>{{ pt.name }}</span>
|
||
<span
|
||
v-if="pt.startTime || pt.endTime"
|
||
class="opacity-70 font-bold"
|
||
>
|
||
{{ pt.startTime || '--' }}-{{ pt.endTime || '--' }}
|
||
</span>
|
||
<span
|
||
v-if="!pt.enabled"
|
||
class="ml-1 px-1.5 py-0.5 text-[9px] bg-zinc-100 rounded text-zinc-500"
|
||
>已禁用</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 自动排期策略 -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div :class="playTimeEnabled ? '' : 'md:col-span-2'">
|
||
<CustomSelect
|
||
:model-value="autoScheduleStrategy"
|
||
label="自动排期策略"
|
||
:options="scheduleStrategyOptions"
|
||
class-name="w-full"
|
||
@update:model-value="handleStrategyChange"
|
||
/>
|
||
</div>
|
||
<!-- 快速统计信息 -->
|
||
<div
|
||
v-if="playTimeEnabled && playTimes.length > 0"
|
||
class="hidden md:flex items-center gap-3 bg-white/80 border border-zinc-200 rounded-xl px-4 py-2"
|
||
>
|
||
<div class="flex-1 flex items-center gap-2">
|
||
<div class="w-8 h-8 rounded-lg bg-blue-600/20 text-blue-700 flex items-center justify-center">
|
||
<ListMusic :size="14" />
|
||
</div>
|
||
<div class="flex flex-col leading-tight">
|
||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">当前列表</span>
|
||
<span class="text-sm font-black text-zinc-700">
|
||
{{ localScheduledSongs.length }} 首 / {{ playTimeScheduledCount }} 首
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="w-px h-8 bg-zinc-100" />
|
||
<div class="flex-1 flex items-center gap-2">
|
||
<div class="w-8 h-8 rounded-lg bg-purple-600/20 text-purple-400 flex items-center justify-center">
|
||
<Inbox :size="14" />
|
||
</div>
|
||
<div class="flex flex-col leading-tight">
|
||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">待排歌曲</span>
|
||
<span class="text-sm font-black text-zinc-700">
|
||
{{ filteredUnscheduledSongs.length }} 首
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 加载状态 -->
|
||
<div v-if="loading" class="flex flex-col items-center justify-center py-20 min-h-[60vh]">
|
||
<LoadingState title="正在加载排期数据" message="请稍候..." />
|
||
</div>
|
||
|
||
<div v-else>
|
||
<div
|
||
class="lg:hidden sticky -top-4 -mx-4 -mt-4 z-20 flex gap-2 p-2 bg-white/90 backdrop-blur-md border-b border-zinc-200 shadow-xl mb-4 pt-4 min-h-[64px]"
|
||
>
|
||
<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-blue-600 text-white shadow-lg' : 'text-zinc-500'
|
||
]"
|
||
@click="mobileTab = 'pending'"
|
||
>
|
||
<ListMusic class="w-4 h-4" />
|
||
<span class="flex items-center gap-1.5"
|
||
>待排歌曲
|
||
<span class="px-1.5 py-0.5 bg-zinc-100 text-[10px] rounded text-zinc-400">{{
|
||
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-blue-600 text-white shadow-lg' : 'text-zinc-500'
|
||
]"
|
||
@click="mobileTab = 'scheduled'"
|
||
>
|
||
<PlaySquare class="w-4 h-4" />
|
||
<span class="flex items-center gap-1.5"
|
||
>播放列表
|
||
<span class="px-1.5 py-0.5 bg-zinc-100 text-[10px] rounded text-zinc-400">{{
|
||
localScheduledSongs.length
|
||
}}</span></span
|
||
>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-4 md:gap-5 min-h-0">
|
||
<!-- 左侧:待排歌曲(待排库) -->
|
||
<div
|
||
v-show="mobileTab === 'pending' || isDesktop"
|
||
:class="[
|
||
'lg:col-span-4 flex flex-col gap-2 md:gap-3',
|
||
mobileTab === 'scheduled' ? 'hidden lg:flex' : 'flex'
|
||
]"
|
||
@dragover.prevent="handleDraggableDragOver"
|
||
@dragleave="handleDraggableDragLeave"
|
||
@drop.stop.prevent="handleReturnToDraggable"
|
||
>
|
||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 md:gap-3 px-1 min-h-[52px]">
|
||
<h3 class="hidden lg:block text-lg font-black tracking-tight text-zinc-900 uppercase">
|
||
待排歌曲
|
||
</h3>
|
||
<div
|
||
class="flex w-full lg:w-auto gap-2 p-1.5 bg-zinc-50/50 rounded-xl border border-zinc-200"
|
||
>
|
||
<button
|
||
v-for="tab in [
|
||
{ id: 'normal', label: '普通投稿' },
|
||
{ id: 'replay', label: '重播申请' },
|
||
{ id: 'all', label: '所有' }
|
||
]"
|
||
:key="tab.id"
|
||
:class="[
|
||
'flex-1 lg:flex-none px-3 md:px-4 py-2 rounded-lg text-[10px] md:text-xs font-black uppercase tracking-widest transition-all',
|
||
activeTab === tab.id
|
||
? 'bg-zinc-100 text-blue-700 shadow-md border border-blue-200'
|
||
: 'text-zinc-600 hover:text-zinc-400'
|
||
]"
|
||
@click="activeTab = tab.id"
|
||
>
|
||
{{ tab.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 筛选区 - 移动端折叠 -->
|
||
<div class="bg-zinc-50/40 border border-zinc-200 rounded-2xl shadow-xl">
|
||
<div
|
||
class="p-3 md:p-4 flex items-center justify-between lg:hidden border-b border-zinc-200 rounded-t-2xl"
|
||
@click="mobileFiltersOpen = !mobileFiltersOpen"
|
||
>
|
||
<div class="flex items-center gap-2 text-zinc-400">
|
||
<Filter class="w-3.5 h-3.5" />
|
||
<span class="text-[11px] font-black uppercase tracking-widest">检索与筛选</span>
|
||
</div>
|
||
<ChevronRight
|
||
:class="[
|
||
'w-3.5 h-3.5 text-zinc-700 transition-transform duration-300',
|
||
mobileFiltersOpen ? 'rotate-90' : ''
|
||
]"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
v-show="mobileFiltersOpen || isDesktop"
|
||
class="p-3 md:p-4 space-y-2 md:space-y-3 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-zinc-700 w-3.5 h-3.5"
|
||
/>
|
||
<input
|
||
v-model="searchQuery"
|
||
type="text"
|
||
placeholder="搜索歌曲、艺术家..."
|
||
class="w-full pl-9 pr-4 py-2 bg-white border border-zinc-200 rounded-xl text-xs focus:outline-none focus:border-blue-200 transition-all text-zinc-700"
|
||
>
|
||
<button
|
||
v-if="searchQuery"
|
||
class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400"
|
||
@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="当前学期"
|
||
:options="availableSemesters"
|
||
label-key="name"
|
||
value-key="name"
|
||
@update:model-value="handleSemesterSelect"
|
||
/>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<CustomSelect v-model="selectedGrade" label="年级" :options="availableGrades" />
|
||
<CustomSelect v-model="songSortOption" label="排序" :options="sortOptions" />
|
||
</div>
|
||
</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-blue-500 bg-blue-500/5'
|
||
: 'border-zinc-200/80 bg-zinc-50/20'
|
||
]"
|
||
>
|
||
<div class="space-y-2">
|
||
<div
|
||
v-for="song in filteredUnscheduledSongs"
|
||
:key="song.id"
|
||
class="draggable-song relative group bg-white border border-zinc-200 rounded-xl p-3 hover:border-zinc-200 transition-all select-none"
|
||
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-zinc-100 flex-shrink-0 border border-zinc-200 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-zinc-600"
|
||
>
|
||
<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">
|
||
<h4 class="font-bold text-zinc-900 text-sm truncate">
|
||
<span
|
||
v-if="false"
|
||
class="text-zinc-900 flex items-center gap-1 w-full text-left"
|
||
>
|
||
<span class="truncate">{{ song.title }}</span>
|
||
</span>
|
||
<span v-else>{{ song.title }}</span>
|
||
</h4>
|
||
<button
|
||
v-if="song.hasSubmissionNote && song.submissionNote"
|
||
class="inline-flex items-center justify-center w-5 h-5 rounded-full border border-blue-200 bg-blue-500/10 text-blue-700 hover:bg-blue-500/20 transition-all flex-shrink-0"
|
||
title="查看备注留言"
|
||
@click.stop="openSubmissionRemark(song)"
|
||
>
|
||
<MessageSquare :size="12" />
|
||
</button>
|
||
</div>
|
||
<div class="text-xs text-zinc-400 truncate">{{ song.artist }}</div>
|
||
<div class="text-[10px] text-zinc-500 truncate flex items-center gap-1">
|
||
<span>{{ song.requester }}</span>
|
||
<span
|
||
v-if="song.preferredPlayTimeId"
|
||
class="ml-1 px-1.5 py-0.5 bg-indigo-500/10 text-indigo-400 rounded text-[9px] border border-indigo-500/20 whitespace-nowrap"
|
||
>
|
||
期望: {{ getPlayTimeName(song.preferredPlayTimeId) }}
|
||
</span>
|
||
<span
|
||
v-if="song.expectedPlayDate"
|
||
class="ml-1 px-1.5 py-0.5 bg-yellow-500/10 text-yellow-500 rounded text-[9px] border border-yellow-500/20 whitespace-nowrap flex items-center gap-1"
|
||
>
|
||
<CalendarDays class="w-2.5 h-2.5" />
|
||
日期: {{ song.expectedPlayDate }}
|
||
</span>
|
||
<span
|
||
v-if="song.expectedPlaySequence !== null && song.expectedPlaySequence !== undefined"
|
||
class="ml-1 px-1.5 py-0.5 bg-emerald-500/10 text-emerald-500 rounded text-[9px] border border-emerald-500/20 whitespace-nowrap flex items-center gap-1"
|
||
>
|
||
<ListMusic class="w-2.5 h-2.5" />
|
||
序号: {{ song.expectedPlaySequence }}
|
||
</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-zinc-500 bg-white/70 px-2 py-1 rounded-md border border-zinc-200"
|
||
>
|
||
<Heart class="w-3 h-3 text-red-500/50" />
|
||
{{ song.voteCount || 0 }}
|
||
</div>
|
||
|
||
<!-- 重播模式:查看按钮 -->
|
||
<button
|
||
v-if="activeTab === 'replay'"
|
||
class="px-3 py-1.5 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-700 border border-blue-200 text-[10px] font-bold transition-colors"
|
||
@click.stop="openReplayModal(song)"
|
||
>
|
||
查看
|
||
</button>
|
||
|
||
<!-- 重播模式:拒绝按钮(仅移动端) -->
|
||
<button
|
||
v-if="activeTab === 'replay'"
|
||
class="lg:hidden p-1.5 rounded-lg bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 transition-colors"
|
||
title="拒绝申请"
|
||
@click.stop="rejectReplayRequest(song.id)"
|
||
>
|
||
<CloseIcon class="w-3.5 h-3.5" />
|
||
</button>
|
||
|
||
<!-- 移动端添加按钮 -->
|
||
<button
|
||
class="lg:hidden p-2 rounded-full bg-blue-600/20 text-blue-700 hover:bg-blue-600/30 active:scale-95 transition-all flex-shrink-0"
|
||
@click.stop="addSongToSchedule(song)"
|
||
>
|
||
<Plus class="w-5 h-5" />
|
||
</button>
|
||
|
||
<!-- 菜单按钮 -->
|
||
<div
|
||
class="p-1.5 rounded-lg bg-white border border-zinc-200 text-zinc-600 cursor-grab active:cursor-grabbing hover:text-zinc-400 transition-colors"
|
||
>
|
||
<MoreVertical class="w-4 h-4" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div
|
||
v-if="filteredUnscheduledSongs.length === 0"
|
||
class="h-[300px] flex flex-col items-center justify-center text-zinc-800"
|
||
>
|
||
<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">未找到匹配的歌曲</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">暂无歌曲记录</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<Pagination
|
||
v-model:current-page="currentPage"
|
||
:total-pages="totalPages"
|
||
:total-items="allUnscheduledSongs.length"
|
||
item-name="首待排歌曲"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧:播放列表(播放顺序) -->
|
||
<div
|
||
v-show="mobileTab === 'scheduled' || isDesktop"
|
||
:class="[
|
||
'lg:col-span-8 flex flex-col gap-3 md:gap-4',
|
||
mobileTab === 'pending' ? 'hidden lg:flex' : 'flex'
|
||
]"
|
||
>
|
||
<div
|
||
class="hidden lg:flex flex-col xl:flex-row xl:items-center justify-between gap-3 md:gap-4 px-1 min-h-[56px]"
|
||
>
|
||
<h3 class="text-lg font-black tracking-tight text-zinc-900 uppercase">播放顺序</h3>
|
||
<div
|
||
class="flex flex-wrap items-center gap-1.5 md:gap-2 p-1.5 bg-zinc-50/50 border border-zinc-200 rounded-2xl"
|
||
>
|
||
<div class="flex gap-1 md:gap-1.5">
|
||
<button
|
||
:disabled="
|
||
!hasChanges && localScheduledSongs.length === 0 && !hasUnpublishedDrafts
|
||
"
|
||
class="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 text-zinc-500 hover:text-zinc-700 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-zinc-100 text-[9px] text-zinc-600 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-200"
|
||
>保存草稿</span
|
||
>
|
||
</button>
|
||
<button
|
||
:disabled="localScheduledSongs.length === 0"
|
||
class="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 text-zinc-500 hover:text-zinc-700 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-zinc-100 text-[9px] text-zinc-600 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-200"
|
||
>下载歌曲</span
|
||
>
|
||
</button>
|
||
<button
|
||
:disabled="localScheduledSongs.length === 0"
|
||
class="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 text-zinc-500 hover:text-blue-700 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
|
||
@click="showCardGenerator = true"
|
||
>
|
||
<ImageIcon class="w-3.5 h-3.5" />
|
||
<span
|
||
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-100 text-[9px] text-zinc-600 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-200"
|
||
>生成图片</span
|
||
>
|
||
</button>
|
||
<button
|
||
:disabled="localScheduledSongs.length === 0"
|
||
class="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 text-zinc-500 hover:text-emerald-500 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-zinc-100 text-[9px] text-zinc-600 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-200"
|
||
>全部已播放</span
|
||
>
|
||
</button>
|
||
<button
|
||
:disabled="localScheduledSongs.length === 0"
|
||
class="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 text-zinc-500 hover:text-cyan-400 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
|
||
title="构建播放队列"
|
||
@click="confirmBuildPlayQueue"
|
||
>
|
||
<Music2 class="w-3.5 h-3.5" />
|
||
<span
|
||
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-100 text-[9px] text-zinc-600 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-200"
|
||
>构建播放队列</span
|
||
>
|
||
</button>
|
||
<button
|
||
class="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 text-zinc-500 hover:text-purple-400 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-zinc-100 text-[9px] text-zinc-600 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-200"
|
||
>迁移日期</span
|
||
>
|
||
</button>
|
||
</div>
|
||
<div class="h-6 w-[1px] bg-zinc-100 mx-1" />
|
||
<button
|
||
class="flex items-center gap-1.5 px-3 md:px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-500 text-[10px] md:text-xs font-black rounded-xl border border-yellow-500/20 transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50"
|
||
title="一键将当前待排歌曲自动排入"
|
||
@click="autoScheduleExpected"
|
||
>
|
||
<CalendarDays class="w-3 h-3" /> 自动排入
|
||
</button>
|
||
<button
|
||
:disabled="!canPublish"
|
||
class="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 text-[10px] md:text-xs font-black rounded-xl border border-emerald-500/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" /> 发布排期
|
||
</button>
|
||
<button
|
||
:disabled="!hasChanges"
|
||
class="flex items-center gap-2 px-5 py-2 bg-blue-600 hover:bg-blue-500 text-white text-[10px] md:text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 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" /> 保存并发布
|
||
</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-blue-500 bg-blue-500/5' : 'border-zinc-200/80 bg-zinc-50/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-zinc-800"
|
||
>
|
||
<PlaySquare class="w-8 h-8 mb-4 opacity-20" />
|
||
<p class="text-[10px] font-black uppercase tracking-widest">请从待排库中添加歌曲</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-white border border-zinc-200 rounded-xl p-3 hover:border-zinc-200 transition-all select-none',
|
||
dragOverIndex === index ? 'border-t-2 border-t-blue-500' : '',
|
||
schedule.isDraft ? 'border-amber-200 bg-amber-500/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-white/70 border border-zinc-200 text-zinc-500 font-black text-xs flex-shrink-0"
|
||
>
|
||
<span class="text-[8px] text-zinc-600 uppercase leading-none mb-0.5">POS</span>
|
||
<span class="text-sm text-zinc-600 leading-none">{{
|
||
index + 1 < 10 ? '0' + (index + 1) : index + 1
|
||
}}</span>
|
||
</div>
|
||
|
||
<!-- 封面图片 -->
|
||
<div
|
||
class="relative w-10 h-10 rounded-lg overflow-hidden bg-zinc-100 flex-shrink-0 border border-zinc-200 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-zinc-600"
|
||
>
|
||
<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">
|
||
<h4 class="font-bold text-zinc-700 text-sm truncate">
|
||
{{ 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-blue-200 bg-blue-500/10 text-blue-700 hover:bg-blue-500/20 transition-all flex-shrink-0"
|
||
title="查看备注留言"
|
||
@click.stop="openSubmissionRemark(schedule.song)"
|
||
>
|
||
<MessageSquare :size="12" />
|
||
</button>
|
||
<!-- 重播标识 -->
|
||
<span
|
||
v-if="schedule.song.replayRequestCount > 0"
|
||
class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-blue-500/10 text-blue-500 border border-blue-200 uppercase tracking-wider flex items-center gap-1"
|
||
title="重播歌曲"
|
||
>
|
||
<Icon name="repeat" :size="10" />
|
||
重播
|
||
</span>
|
||
<span
|
||
v-if="schedule.isDraft"
|
||
class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-amber-500/10 text-amber-500 border border-amber-500/20 uppercase tracking-wider"
|
||
>草稿</span
|
||
>
|
||
</div>
|
||
<div class="text-xs text-zinc-500 truncate">{{ schedule.song.artist }}</div>
|
||
<div class="text-[10px] text-zinc-600 truncate flex items-center gap-1">
|
||
<!-- 显示申请人或投稿人 -->
|
||
<span
|
||
v-if="schedule.song.replayRequestCount > 0"
|
||
:title="
|
||
'重播申请人:' +
|
||
(schedule.song.replayRequesters || [])
|
||
.map((r) => r.displayName || r.name)
|
||
.join('、')
|
||
"
|
||
>
|
||
申请人:
|
||
{{
|
||
(schedule.song.replayRequesters || [])
|
||
.slice(0, 2)
|
||
.map((r) => r.displayName || r.name)
|
||
.join('、')
|
||
}}{{
|
||
schedule.song.replayRequestCount > 2
|
||
? ' 等' + schedule.song.replayRequestCount + '人'
|
||
: ''
|
||
}}
|
||
</span>
|
||
<span v-else>{{ schedule.song.requester }}</span>
|
||
<span
|
||
v-if="schedule.song.requesterGrade || schedule.song.grade"
|
||
class="text-zinc-700"
|
||
>|</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-indigo-500/10 text-indigo-400 rounded text-[9px] border border-indigo-500/20 whitespace-nowrap"
|
||
>
|
||
期望: {{ 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-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 border border-emerald-500/20 transition-colors"
|
||
title="发布此草稿"
|
||
@click="publishSingleDraft(schedule)"
|
||
>
|
||
<Send class="w-3.5 h-3.5" />
|
||
</button>
|
||
|
||
<!-- 移动端删除按钮 -->
|
||
<button
|
||
class="lg:hidden p-2 rounded-full bg-red-500/20 text-red-500 hover:bg-red-500/30 active:scale-95 transition-all flex-shrink-0"
|
||
@click.stop="removeSongFromSchedule(schedule)"
|
||
>
|
||
<Minus class="w-5 h-5" />
|
||
</button>
|
||
|
||
<div
|
||
class="p-1.5 rounded-lg bg-white border border-zinc-200 text-zinc-600 cursor-grab active:cursor-grabbing hover:text-zinc-400 transition-colors"
|
||
>
|
||
<MoreVertical class="w-4 h-4" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</TransitionGroup>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 移动端底部操作栏 -->
|
||
<div
|
||
class="lg:hidden fixed bottom-0 left-0 right-0 z-40 p-3 bg-white/90 backdrop-blur-xl border-t border-zinc-200 flex items-center gap-3 pb-6"
|
||
>
|
||
<div class="w-[240px] overflow-x-auto scrollbar-hide">
|
||
<div class="flex items-center gap-2 w-max">
|
||
<button
|
||
class="w-11 h-11 shrink-0 bg-white border border-zinc-200 text-zinc-400 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-white border border-zinc-200 text-zinc-400 rounded-xl flex items-center justify-center active:scale-95 transition-all"
|
||
@click="showCardGenerator = true"
|
||
>
|
||
<ImageIcon class="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
class="w-11 h-11 shrink-0 bg-white border border-zinc-200 text-zinc-400 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-white border border-zinc-200 text-emerald-500 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-white border border-zinc-200 text-purple-400 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-white border border-zinc-200 text-blue-500 rounded-xl flex items-center justify-center active:scale-95 transition-all"
|
||
title="仅发布排期"
|
||
@click="publishSchedule"
|
||
>
|
||
<Send class="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主要操作 -->
|
||
<!-- 清空当天排期:破坏性动作,独立出来(发布按钮不再兼任删除) -->
|
||
<button
|
||
:disabled="localScheduledSongs.length === 0"
|
||
class="shrink-0 w-11 h-11 bg-white border border-red-200 text-red-500 rounded-xl flex items-center justify-center active:scale-95 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||
title="删除当天全部排期"
|
||
@click="clearTodaySchedule"
|
||
>
|
||
<Trash2 class="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
:disabled="!hasChanges"
|
||
class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black uppercase tracking-widest rounded-xl shadow-lg shadow-blue-900/20 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" /> 保存并发布
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 歌单图片生成器 -->
|
||
<PlaylistCardGenerator
|
||
:show="showCardGenerator"
|
||
:songs="localScheduledSongs"
|
||
:default-title="selectedDate + ' 校园广播歌单'"
|
||
@close="showCardGenerator = false"
|
||
/>
|
||
|
||
<!-- 确认对话框 -->
|
||
<ConfirmDialog
|
||
:confirm-text="confirmDialogConfirmText"
|
||
:loading="loading"
|
||
:message="confirmDialogMessage"
|
||
:show="showConfirmDialog"
|
||
:title="confirmDialogTitle"
|
||
:type="confirmDialogType"
|
||
cancel-text="取消"
|
||
@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-black/60 backdrop-blur-sm"
|
||
>
|
||
<div
|
||
class="bg-white border border-zinc-200 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-zinc-200">
|
||
<h3 class="text-sm font-black text-zinc-900 uppercase tracking-widest">迁移排期日期</h3>
|
||
<button
|
||
class="text-zinc-500 hover:text-zinc-600 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-zinc-500">当前日期:{{ selectedDate }}</div>
|
||
<input
|
||
v-model="moveTargetDate"
|
||
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-3 text-zinc-700 focus:outline-none focus:border-purple-500 transition-colors"
|
||
type="date"
|
||
>
|
||
<div class="flex gap-3">
|
||
<button
|
||
class="flex-1 py-3 bg-zinc-100 hover:bg-zinc-200 text-zinc-600 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
|
||
@click="showMoveDateDialog = false"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
class="flex-1 py-3 bg-purple-600 hover:bg-purple-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-purple-900/20 transition-colors uppercase tracking-wider"
|
||
@click="confirmMoveDate"
|
||
>
|
||
下一步
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 重播申请详情弹窗 -->
|
||
<div
|
||
v-if="showReplayModal"
|
||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||
@click="closeReplayModal"
|
||
>
|
||
<div
|
||
class="bg-white border border-zinc-200 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-zinc-200">
|
||
<h3 class="text-sm font-black text-zinc-900 uppercase tracking-widest">
|
||
{{ replayModalTitle }} - 重播申请详情
|
||
</h3>
|
||
<div class="flex items-center gap-3">
|
||
<button
|
||
class="px-3 py-1.5 bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 rounded-lg text-xs font-bold transition-colors"
|
||
@click="
|
||
rejectReplayRequest(replayModalSongId);
|
||
closeReplayModal()
|
||
"
|
||
>
|
||
拒绝申请
|
||
</button>
|
||
<button
|
||
class="text-zinc-500 hover:text-zinc-600 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-zinc-800/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-white border border-zinc-200 flex items-center justify-center text-zinc-500 group-hover:text-blue-700 transition-colors"
|
||
>
|
||
<User class="w-3.5 h-3.5" />
|
||
</div>
|
||
<div class="flex flex-col">
|
||
<span class="text-sm font-bold text-zinc-700">{{ req.name }}</span>
|
||
<span v-if="req.grade" class="text-[10px] text-zinc-500"
|
||
>{{ req.grade }}{{ req.class ? ` ${req.class}` : '' }}</span
|
||
>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="flex items-center gap-1.5 text-[10px] font-black text-zinc-600 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-zinc-700">
|
||
<Info class="w-6 h-6 mx-auto mb-2 opacity-20" />
|
||
<p class="text-xs font-bold uppercase tracking-widest">暂无详细申请记录</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<SubmissionRemarkDialog
|
||
:show="submissionRemarkDialog.show"
|
||
:song-title="submissionRemarkDialog.songTitle"
|
||
:content="submissionRemarkDialog.content"
|
||
:is-public="submissionRemarkDialog.isPublic"
|
||
@close="submissionRemarkDialog.show = false"
|
||
/>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch, inject } from 'vue'
|
||
import {
|
||
Search,
|
||
Save,
|
||
Send,
|
||
CheckCircle2,
|
||
Download,
|
||
FileBadge,
|
||
PlaySquare,
|
||
ChevronDown,
|
||
ListMusic,
|
||
Filter,
|
||
Info,
|
||
Clock,
|
||
User,
|
||
AlertTriangle,
|
||
X as CloseIcon,
|
||
ChevronRight,
|
||
MoreVertical,
|
||
Calendar as CalendarIcon,
|
||
ArrowLeft,
|
||
ArrowRight,
|
||
Music2,
|
||
Heart,
|
||
Plus,
|
||
Minus,
|
||
CircleDot,
|
||
ExternalLink,
|
||
MessageSquare,
|
||
Image,
|
||
CalendarDays,
|
||
Image as ImageIcon,
|
||
Layers,
|
||
Inbox,
|
||
Trash2
|
||
} from '@lucide/vue'
|
||
import AdminDatePicker from './AdminDatePicker.vue'
|
||
import PlaylistCardGenerator from './PlaylistCardGenerator.vue'
|
||
import SongDownloadDialog from './SongDownloadDialog.vue'
|
||
import SubmissionRemarkDialog from './SubmissionRemarkDialog.vue'
|
||
import ConfirmDialog from '../UI/ConfirmDialog.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 { useAudioPlayer, type PlayableSong } from '~/composables/useAudioPlayer'
|
||
import { useToast } from '~/composables/useToast'
|
||
import { convertToHttps } from '~/utils/url'
|
||
|
||
const STORAGE_KEY = 'voicehub:schedule:auto-config'
|
||
|
||
type AutoScheduleStrategy = 'default' | 'contributor-count'
|
||
|
||
interface AutoConfig {
|
||
strategy: AutoScheduleStrategy
|
||
selectedPlayTime: string
|
||
selectedGrade: string
|
||
songSortOption: string
|
||
activeTab: string
|
||
searchQuery: string
|
||
selectedSemester: string
|
||
}
|
||
|
||
const defaultAutoConfig: AutoConfig = {
|
||
strategy: 'default',
|
||
selectedPlayTime: '',
|
||
selectedGrade: '全部',
|
||
songSortOption: 'votes-desc',
|
||
activeTab: 'normal',
|
||
searchQuery: '',
|
||
selectedSemester: ''
|
||
}
|
||
|
||
// 响应式数据
|
||
const showCardGenerator = ref(false)
|
||
const _pad = (n) => n.toString().padStart(2, '0')
|
||
const _today = new Date(); _today.setHours(0, 0, 0, 0)
|
||
const selectedDate = ref(`${_today.getFullYear()}-${_pad(_today.getMonth() + 1)}-${_pad(_today.getDate())}`)
|
||
const loading = ref(false)
|
||
const songSortOption = ref<AutoConfig['songSortOption']>('votes-desc')
|
||
const hasChanges = ref(false)
|
||
const searchQuery = ref('')
|
||
const selectedGrade = ref('全部')
|
||
const activeTab = ref<AutoConfig['activeTab']>('normal')
|
||
const mobileTab = ref('pending')
|
||
const mobileFiltersOpen = ref(false)
|
||
|
||
const autoScheduleStrategy = ref<AutoScheduleStrategy>('default')
|
||
const scheduleStrategyOptions = [
|
||
{ label: '默认策略(按期望序号)', value: 'default' },
|
||
{ label: '按投稿人数优先覆盖', value: 'contributor-count' }
|
||
]
|
||
|
||
// 音频播放器与 Toast
|
||
const { playSong } = useSongPlayer()
|
||
const audioPlayer = useAudioPlayer()
|
||
const { showToast } = useToast()
|
||
|
||
// 确认对话框相关
|
||
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 showMoveDateDialog = ref(false)
|
||
const moveTargetDate = ref('')
|
||
const submissionRemarkDialog = ref({
|
||
show: false,
|
||
songTitle: '',
|
||
content: '',
|
||
isPublic: true
|
||
})
|
||
|
||
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) => {
|
||
if (!song?.submissionNote) return
|
||
submissionRemarkDialog.value = {
|
||
show: true,
|
||
songTitle: `${song.title} - ${song.artist}`,
|
||
content: song.submissionNote,
|
||
isPublic: song.submissionNotePublic === true
|
||
}
|
||
}
|
||
|
||
// 拖拽状态
|
||
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 sequenceList = ref(null)
|
||
|
||
// 数据
|
||
const songs = ref([])
|
||
const publicSchedules = ref([])
|
||
const localScheduledSongs = ref([])
|
||
const replayRequests = ref([])
|
||
const scheduledSongIds = ref(new Set())
|
||
|
||
// 跨日期本地暂存池:保存未保存但分配到其他日期的排期,避免切换日期时丢失
|
||
const crossDateLocalPool = ref([])
|
||
|
||
// 计算是否有未发布的草稿
|
||
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 playTimeOptions = computed(() => {
|
||
const options = [{ label: '未选择时段 (全天)', 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 playTimeScheduledCount = computed(() => {
|
||
if (!selectedPlayTime.value) return localScheduledSongs.length
|
||
try {
|
||
const ptId = parseInt(selectedPlayTime.value)
|
||
return (playTimes.value || []).reduce((sum, pt) => {
|
||
if (pt.id === ptId) return sum
|
||
return sum
|
||
}, 0)
|
||
} catch (e) {
|
||
return localScheduledSongs.length
|
||
}
|
||
})
|
||
|
||
// 排序选项
|
||
const sortOptions = [
|
||
{ label: '最新投稿', value: 'time-desc' },
|
||
{ label: '最早投稿', value: 'time-asc' },
|
||
{ label: '热度最高', value: 'votes-desc' },
|
||
{ label: '热度最低', value: 'votes-asc' }
|
||
]
|
||
|
||
// 策略变更处理
|
||
const handleStrategyChange = (value: string) => {
|
||
autoScheduleStrategy.value = value as AutoScheduleStrategy
|
||
}
|
||
|
||
// 本地存储工具
|
||
const loadAutoConfig = (): AutoConfig => {
|
||
if (typeof window === 'undefined') return { ...defaultAutoConfig }
|
||
try {
|
||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||
if (!raw) return { ...defaultAutoConfig }
|
||
const parsed = JSON.parse(raw) as Partial<AutoConfig>
|
||
return { ...defaultAutoConfig, ...parsed }
|
||
} catch (err) {
|
||
console.warn('[ScheduleConfig] 解析本地配置失败,使用默认值', err)
|
||
return { ...defaultAutoConfig }
|
||
}
|
||
}
|
||
|
||
const saveAutoConfig = () => {
|
||
if (typeof window === 'undefined') return
|
||
try {
|
||
const config: AutoConfig = {
|
||
strategy: autoScheduleStrategy.value,
|
||
selectedPlayTime: selectedPlayTime.value,
|
||
selectedGrade: selectedGrade.value,
|
||
songSortOption: songSortOption.value,
|
||
activeTab: activeTab.value,
|
||
searchQuery: searchQuery.value,
|
||
selectedSemester: selectedSemester.value
|
||
}
|
||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||
} catch (err) {
|
||
console.warn('[ScheduleConfig] 保存本地配置失败', err)
|
||
}
|
||
}
|
||
|
||
// Debounce 工具
|
||
let configSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||
const debounceSaveAutoConfig = () => {
|
||
if (configSaveTimer) clearTimeout(configSaveTimer)
|
||
configSaveTimer = setTimeout(() => {
|
||
saveAutoConfig()
|
||
configSaveTimer = null
|
||
}, 500)
|
||
}
|
||
|
||
// 投稿人联合数计算(requesterId + collaborators ACCEPTED)
|
||
const getSongContributorCount = (song: any): number => {
|
||
let count = 1
|
||
if (song.collaborators && Array.isArray(song.collaborators)) {
|
||
count += song.collaborators.length
|
||
}
|
||
return count
|
||
}
|
||
|
||
// 学期相关
|
||
const availableSemesters = ref([])
|
||
const selectedSemester = ref('')
|
||
|
||
// 手动日期选择
|
||
|
||
// 分页相关
|
||
const pageStates = reactive({
|
||
normal: 1,
|
||
replay: 1,
|
||
all: 1
|
||
})
|
||
const currentPage = computed({
|
||
get: () => pageStates[activeTab.value] || 1,
|
||
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 availableGrades = computed(() => {
|
||
if (!songs.value) return ['全部']
|
||
|
||
const grades = new Set()
|
||
songs.value.forEach((song) => {
|
||
if (song.requesterGrade) {
|
||
grades.add(song.requesterGrade)
|
||
}
|
||
})
|
||
|
||
// 对年级进行简单排序
|
||
const sortedGrades = Array.from(grades).sort()
|
||
return ['全部', ...sortedGrades]
|
||
})
|
||
|
||
// 过滤未排期歌曲(所有)
|
||
const allUnscheduledSongs = computed(() => {
|
||
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 {
|
||
// 普通投稿需未播放,且未在任何日期的排期中
|
||
return !song.played && !scheduledSongIds.value.has(song.id)
|
||
}
|
||
})
|
||
|
||
// 搜索过滤
|
||
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
|
||
)
|
||
}
|
||
|
||
return [...unscheduledSongs].sort((a, b) => {
|
||
// 重播申请默认按申请数量降序排列,如果数量相同则按时间
|
||
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) => {
|
||
const date = new Date(dateString)
|
||
const now = new Date()
|
||
const diff = now - date
|
||
|
||
if (diff < 60000) return '刚刚'
|
||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||
return `${Math.floor(diff / 86400000)}天前`
|
||
}
|
||
|
||
// 检查窗口大小
|
||
const checkWindowSize = () => {
|
||
isDesktop.value = window.innerWidth >= 1024
|
||
}
|
||
|
||
// 确认对话框处理
|
||
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('您有未保存的排期修改,确定要离开吗?')
|
||
if (answer) {
|
||
next()
|
||
} else {
|
||
next(false)
|
||
}
|
||
} else {
|
||
next()
|
||
}
|
||
})
|
||
|
||
// 处理日期选择
|
||
const handleDateSelect = (dateValue) => {
|
||
if (selectedDate.value === dateValue) return
|
||
|
||
if (hasChanges.value) {
|
||
if (!window.confirm('您有未保存的排期修改,切换日期将丢失这些修改,确定要继续吗?')) {
|
||
return
|
||
}
|
||
}
|
||
selectedDate.value = dateValue
|
||
}
|
||
|
||
// 处理播出时段选择
|
||
const handlePlayTimeSelect = (value) => {
|
||
if (selectedPlayTime.value === value) return
|
||
|
||
if (hasChanges.value) {
|
||
if (!window.confirm('您有未保存的排期修改,切换时段将丢失这些修改,确定要继续吗?')) {
|
||
return
|
||
}
|
||
}
|
||
selectedPlayTime.value = value
|
||
}
|
||
|
||
// 处理学期选择
|
||
const handleSemesterSelect = async (value) => {
|
||
if (selectedSemester.value === value) return
|
||
|
||
if (hasChanges.value) {
|
||
if (!window.confirm('您有未保存的排期修改,切换学期将丢失这些修改,确定要继续吗?')) {
|
||
return
|
||
}
|
||
}
|
||
selectedSemester.value = value
|
||
await onSemesterChange()
|
||
}
|
||
|
||
// 初始化
|
||
let unregisterBeforeNavigate = null
|
||
const registerBeforeNavigate = inject('registerBeforeNavigate', null)
|
||
|
||
let refreshInterval = null
|
||
|
||
onMounted(async () => {
|
||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||
|
||
if (registerBeforeNavigate) {
|
||
unregisterBeforeNavigate = registerBeforeNavigate(() => {
|
||
if (hasChanges.value) {
|
||
return window.confirm('您有未保存的排期修改,切换页面将丢失这些修改,确定要继续吗?')
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
|
||
songsService = useSongs()
|
||
adminService = useAdmin()
|
||
auth = useAuth()
|
||
semesterService = useSemesters()
|
||
|
||
checkWindowSize()
|
||
window.addEventListener('resize', checkWindowSize)
|
||
|
||
const savedConfig = loadAutoConfig()
|
||
autoScheduleStrategy.value = savedConfig.strategy
|
||
selectedPlayTime.value = savedConfig.selectedPlayTime
|
||
selectedGrade.value = savedConfig.selectedGrade
|
||
songSortOption.value = savedConfig.songSortOption
|
||
activeTab.value = savedConfig.activeTab
|
||
searchQuery.value = savedConfig.searchQuery
|
||
if (savedConfig.selectedSemester) {
|
||
selectedSemester.value = savedConfig.selectedSemester
|
||
}
|
||
|
||
await loadSemesters()
|
||
if (savedConfig.selectedSemester && !selectedSemester.value) {
|
||
selectedSemester.value = savedConfig.selectedSemester
|
||
}
|
||
await loadData()
|
||
|
||
refreshInterval = setInterval(() => {
|
||
if (!hasChanges.value && !loading.value) {
|
||
loadData(true)
|
||
}
|
||
}, 10000)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||
|
||
if (unregisterBeforeNavigate) {
|
||
unregisterBeforeNavigate()
|
||
}
|
||
|
||
window.removeEventListener('resize', checkWindowSize)
|
||
if (autoSaveTimer) {
|
||
clearTimeout(autoSaveTimer)
|
||
autoSaveTimer = null
|
||
}
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval)
|
||
}
|
||
if (configSaveTimer) {
|
||
clearTimeout(configSaveTimer)
|
||
configSaveTimer = null
|
||
}
|
||
})
|
||
|
||
watch(
|
||
[
|
||
autoScheduleStrategy,
|
||
selectedPlayTime,
|
||
selectedGrade,
|
||
songSortOption,
|
||
activeTab,
|
||
searchQuery,
|
||
selectedSemester
|
||
],
|
||
() => {
|
||
debounceSaveAutoConfig()
|
||
}
|
||
)
|
||
|
||
// 监听日期变化
|
||
watch(selectedDate, async () => {
|
||
await loadData()
|
||
})
|
||
|
||
// 重置所有分页状态
|
||
const resetAllPages = () => {
|
||
pageStates.normal = 1
|
||
pageStates.replay = 1
|
||
pageStates.all = 1
|
||
}
|
||
|
||
// 监听排序选项变化,重置分页
|
||
watch(songSortOption, () => {
|
||
resetAllPages()
|
||
})
|
||
|
||
// 监听搜索查询变化,重置分页
|
||
watch(searchQuery, () => {
|
||
resetAllPages()
|
||
})
|
||
|
||
// 监听年级筛选变化,重置分页
|
||
watch(selectedGrade, () => {
|
||
resetAllPages()
|
||
})
|
||
|
||
// 加载重播申请
|
||
const fetchReplayRequests = async () => {
|
||
try {
|
||
const data = await $fetch('/api/admin/replay-requests', {
|
||
...auth.getAuthConfig(),
|
||
query: { status: 'PENDING', t: Date.now() }
|
||
})
|
||
replayRequests.value = data || []
|
||
} catch (err) {
|
||
console.error('Failed to fetch replay requests', err)
|
||
replayRequests.value = []
|
||
}
|
||
}
|
||
|
||
// 拒绝重播申请
|
||
const rejectReplayRequest = async (songId) => {
|
||
confirmDialogTitle.value = '拒绝重播申请'
|
||
confirmDialogMessage.value = '确定要拒绝该重播申请吗?'
|
||
confirmDialogType.value = 'warning'
|
||
confirmDialogConfirmText.value = '拒绝申请'
|
||
|
||
confirmAction.value = async () => {
|
||
try {
|
||
await $fetch('/api/admin/replay-requests/reject', {
|
||
method: 'POST',
|
||
body: { songId },
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
// 刷新申请列表
|
||
await fetchReplayRequests()
|
||
if (window.$showNotification) {
|
||
window.$showNotification('重播申请已拒绝', 'success')
|
||
}
|
||
} catch (err) {
|
||
console.error('拒绝申请失败', err)
|
||
if (window.$showNotification) {
|
||
window.$showNotification('拒绝申请失败: ' + (err.data?.message || err.message), 'error')
|
||
}
|
||
}
|
||
}
|
||
|
||
showConfirmDialog.value = true
|
||
}
|
||
|
||
// 加载数据
|
||
const loadData = async (silent = false) => {
|
||
if (!silent) loading.value = true
|
||
try {
|
||
// 使用选中的学期过滤歌曲,如果选择"全部"则不传递学期参数
|
||
const semester = selectedSemester.value === '全部' ? undefined : selectedSemester.value
|
||
|
||
// 播放列表应该始终显示当前学期的歌曲(或者全部,如果未设置当前学期),不受待排歌曲学期选择的影响
|
||
const playlistSemester = semesterService?.currentSemester?.value?.name
|
||
|
||
// 并行加载数据
|
||
await Promise.all([
|
||
songsService.fetchSongs(false, semester, true, true),
|
||
songsService.fetchPublicSchedules(false, playlistSemester, true, true),
|
||
loadPlayTimes(),
|
||
loadDrafts(), // 加载草稿列表
|
||
fetchReplayRequests() // 加载重播申请
|
||
])
|
||
|
||
songs.value = songsService.songs.value
|
||
publicSchedules.value = songsService.publicSchedules.value
|
||
|
||
// 在草稿加载完成后再更新本地排期数据
|
||
updateLocalScheduledSongs()
|
||
if (!silent) hasChanges.value = false
|
||
lastSavedDraftSignature.value = buildDraftSignature()
|
||
} catch (error) {
|
||
console.error('加载数据失败:', error)
|
||
} finally {
|
||
if (!silent) loading.value = false
|
||
}
|
||
}
|
||
|
||
// 加载播出时段
|
||
const loadPlayTimes = async () => {
|
||
try {
|
||
const response = await $fetch('/api/play-times', {
|
||
query: { t: Date.now() }
|
||
})
|
||
playTimeEnabled.value = response.enabled
|
||
playTimes.value = response.playTimes || []
|
||
} catch (error) {
|
||
console.error('加载播出时段失败:', error)
|
||
playTimeEnabled.value = false
|
||
playTimes.value = []
|
||
}
|
||
}
|
||
|
||
// 格式化播出时段时间范围
|
||
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 `${start} 开始`
|
||
} else if (playTime.endTime) {
|
||
return `${end} 结束`
|
||
}
|
||
|
||
return '全天'
|
||
}
|
||
|
||
// 获取播出时段名称
|
||
const getPlayTimeName = (playTimeId) => {
|
||
if (!playTimeId || !playTimes.value) return ''
|
||
const playTime = playTimes.value.find((pt) => pt.id === playTimeId)
|
||
return playTime ? playTime.name : ''
|
||
}
|
||
|
||
// 加载学期列表
|
||
const loadSemesters = async () => {
|
||
try {
|
||
await semesterService.fetchSemesters()
|
||
await semesterService.fetchCurrentSemester()
|
||
|
||
// 构建学期列表,包含"全部"选项和各个学期
|
||
const semesterList = [{ id: 'all', name: '全部', 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.name
|
||
} else if (semesterList.length > 0) {
|
||
selectedSemester.value = semesterList[0].name
|
||
}
|
||
} catch (error) {
|
||
console.error('获取学期列表失败:', error)
|
||
}
|
||
}
|
||
|
||
// 学期切换处理
|
||
const onSemesterChange = async () => {
|
||
// 学期切换后重新加载数据
|
||
await loadData()
|
||
}
|
||
|
||
// 更新本地排期数据(包括草稿)
|
||
const updateLocalScheduledSongs = () => {
|
||
// 第一步:把当前 localScheduledSongs 中的本地暂存(isLocalOnly)回收到跨日期池
|
||
for (const s of localScheduledSongs.value) {
|
||
if (s.isLocalOnly) {
|
||
const idx = crossDateLocalPool.value.findIndex(p => p.id === s.id)
|
||
if (idx === -1) crossDateLocalPool.value.push(s)
|
||
else crossDateLocalPool.value[idx] = s
|
||
}
|
||
}
|
||
// 清理池子里已经在公开排期或草稿中的(避免重复)
|
||
crossDateLocalPool.value = crossDateLocalPool.value.filter(p => {
|
||
return ![...publicSchedules.value, ...drafts.value].some(s => s.id === p.id || (s.song?.id && p.song?.id && s.song.id === p.song.id))
|
||
})
|
||
|
||
console.log('更新本地排期数据 - 当前日期:', selectedDate.value)
|
||
console.log('公开排期数量:', publicSchedules.value.length)
|
||
console.log('草稿数量:', drafts.value.length)
|
||
console.log('跨日期本地暂存池数量:', crossDateLocalPool.value.length)
|
||
|
||
const _fmtLocal = (d) => {
|
||
const x = new Date(d)
|
||
return `${x.getFullYear()}-${_pad(x.getMonth() + 1)}-${_pad(x.getDate())}`
|
||
}
|
||
|
||
// 获取已发布的排期
|
||
const todaySchedules = publicSchedules.value.filter((s) => {
|
||
if (!s.playDate) return false
|
||
const scheduleDateStr = _fmtLocal(s.playDate)
|
||
return scheduleDateStr === selectedDate.value
|
||
})
|
||
|
||
// 获取草稿排期
|
||
const todayDrafts = drafts.value.filter((draft) => {
|
||
if (!draft.playDate) return false
|
||
const draftDateStr = _fmtLocal(draft.playDate)
|
||
return draftDateStr === selectedDate.value
|
||
})
|
||
|
||
// 获取本地暂存池中属于当前日期的
|
||
const todayLocalOnly = crossDateLocalPool.value.filter((p) => {
|
||
return p.playDate === selectedDate.value
|
||
})
|
||
// 从池中移除今天的,因为接下来要放入 localScheduledSongs
|
||
crossDateLocalPool.value = crossDateLocalPool.value.filter(p => p.playDate !== selectedDate.value)
|
||
|
||
console.log('当天已发布排期:', todaySchedules.length)
|
||
console.log('当天草稿排期:', todayDrafts.length)
|
||
console.log('当天本地暂存排期:', todayLocalOnly.length)
|
||
|
||
// 合并已发布、草稿、本地暂存
|
||
let allSchedules = [...todaySchedules, ...todayDrafts, ...todayLocalOnly]
|
||
|
||
// 如果选择了特定播出时段,进行过滤
|
||
if (selectedPlayTime.value) {
|
||
const ptId = parseInt(selectedPlayTime.value)
|
||
allSchedules = allSchedules.filter((s) => {
|
||
if (!selectedPlayTime.value) return true
|
||
if (s.playTimeId === ptId) return true
|
||
// 如果未选择时段(为空)则也不显示,除非时段本身就是空
|
||
if (s.playTimeId == null && ptId == null) return true
|
||
return s.playTimeId === ptId
|
||
})
|
||
}
|
||
|
||
// 去重(根据 song.id):保留优先级:已发布 > 草稿 > 本地暂存
|
||
const seenIds = new Set()
|
||
allSchedules = allSchedules.filter(s => {
|
||
const key = s.song?.id || s.songId || s.id
|
||
if (!key) return true
|
||
if (seenIds.has(key)) return false
|
||
seenIds.add(key)
|
||
return true
|
||
})
|
||
|
||
// 按 sequence 字段排序
|
||
allSchedules.sort((a, b) => {
|
||
const sa = a.sequence || 0
|
||
const sb = b.sequence || 0
|
||
if (sa !== sb) return sa - sb
|
||
return (a.id || 0) - (b.id || 0)
|
||
})
|
||
|
||
localScheduledSongs.value = allSchedules.map((s) => ({ ...s }))
|
||
|
||
console.log('最终显示排期数量:', localScheduledSongs.value.length)
|
||
|
||
// 更新已排期歌曲ID集合(包括草稿、跨日期暂存、公开排期)
|
||
const allScheduledSongs = [...publicSchedules.value, ...drafts.value, ...crossDateLocalPool.value, ...localScheduledSongs.value]
|
||
scheduledSongIds.value = new Set(
|
||
allScheduledSongs
|
||
.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
|
||
})
|
||
)
|
||
|
||
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') {
|
||
const songId = parseInt(dragData.songId)
|
||
// 尝试在普通歌曲列表和重播申请列表中查找
|
||
let song = songs.value.find((s) => s.id === songId)
|
||
if (!song) {
|
||
song = replayRequests.value.find((s) => s.id === songId)
|
||
}
|
||
|
||
if (!song) return
|
||
|
||
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song.id === songId)
|
||
if (existingIndex !== -1) return
|
||
|
||
const newSchedule = {
|
||
id: Date.now(),
|
||
song: song,
|
||
playDate: selectedDate.value, // 直接使用日期字符串
|
||
sequence: localScheduledSongs.value.length + 1,
|
||
isNew: true,
|
||
isLocalOnly: true
|
||
}
|
||
|
||
scheduledSongIds.value.add(songId)
|
||
localScheduledSongs.value.push(newSchedule)
|
||
hasChanges.value = true
|
||
}
|
||
} 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') {
|
||
// 处理从左侧拖到特定位置
|
||
const songId = parseInt(dragData.songId)
|
||
// 尝试在普通歌曲列表和重播申请列表中查找
|
||
let song = songs.value.find((s) => s.id === songId)
|
||
if (!song) {
|
||
song = replayRequests.value.find((s) => s.id === songId)
|
||
}
|
||
|
||
if (!song) return
|
||
|
||
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song.id === songId)
|
||
if (existingIndex !== -1) return
|
||
|
||
const newSchedule = {
|
||
id: Date.now(),
|
||
song: song,
|
||
playDate: selectedDate.value, // 直接使用日期字符串
|
||
sequence: dropIndex + 1,
|
||
isNew: true
|
||
}
|
||
|
||
scheduledSongIds.value.add(songId)
|
||
|
||
const newOrder = [...localScheduledSongs.value]
|
||
newOrder.splice(dropIndex, 0, newSchedule)
|
||
|
||
newOrder.forEach((item, index) => {
|
||
item.sequence = index + 1
|
||
})
|
||
|
||
localScheduledSongs.value = newOrder
|
||
hasChanges.value = true
|
||
}
|
||
} catch (err) {
|
||
console.error('处理重排序失败:', err)
|
||
}
|
||
|
||
draggedSchedule.value = null
|
||
}
|
||
|
||
// 添加歌曲到排期(点击方式)
|
||
const addSongToSchedule = (song) => {
|
||
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song.id === song.id)
|
||
if (existingIndex !== -1) return
|
||
|
||
const newSchedule = {
|
||
id: Date.now(),
|
||
song: song,
|
||
playDate: selectedDate.value,
|
||
sequence: localScheduledSongs.value.length + 1,
|
||
isNew: true,
|
||
isLocalOnly: true
|
||
}
|
||
|
||
scheduledSongIds.value.add(song.id)
|
||
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)
|
||
}
|
||
|
||
// 重新排序
|
||
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)
|
||
}
|
||
|
||
// 重新排序
|
||
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 = '标记全部已播'
|
||
confirmDialogMessage.value = '确定要将列表中的所有歌曲标记为已播放吗?'
|
||
confirmDialogType.value = 'info'
|
||
confirmDialogConfirmText.value = '确认标记'
|
||
|
||
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('所有歌曲已标记为播放', 'success')
|
||
}
|
||
|
||
// 重新加载数据
|
||
await loadData()
|
||
} catch (err) {
|
||
console.error('标记播放失败:', err)
|
||
if (window.$showNotification) {
|
||
window.$showNotification('操作失败', 'error')
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
showConfirmDialog.value = true
|
||
}
|
||
|
||
// 保存并发布
|
||
const saveSequence = async () => {
|
||
try {
|
||
await publishSchedule()
|
||
} catch (err) {
|
||
console.error('保存并发布失败:', err)
|
||
}
|
||
}
|
||
|
||
// 一键自动排入期望播出的歌曲
|
||
const autoScheduleExpected = () => {
|
||
let count = 0
|
||
|
||
// 1. 收集所有待排期歌曲(不做严格的日期过滤,根据期望日期自动分配)
|
||
const pool = [...allUnscheduledSongs.value]
|
||
|
||
if (pool.length === 0) {
|
||
showToast('当前没有待排期的歌曲', 'info')
|
||
return
|
||
}
|
||
|
||
// 2. 排序(和之前保持一致:按策略)
|
||
if (autoScheduleStrategy.value === 'contributor-count') {
|
||
pool.sort((a, b) => {
|
||
const diffA = getSongContributorCount(b) - getSongContributorCount(a)
|
||
if (diffA !== 0) return diffA
|
||
const seqDiff = (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999)
|
||
if (seqDiff !== 0) return seqDiff
|
||
return (b.voteCount || 0) - (a.voteCount || 0)
|
||
})
|
||
} else {
|
||
pool.sort((a, b) => (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999))
|
||
}
|
||
|
||
const baseId = Date.now()
|
||
const filteredPtId = selectedPlayTime.value ? parseInt(selectedPlayTime.value) : null
|
||
|
||
// 3. 初始化每个日期 × 时段的序列计数器
|
||
// 结构: dateStr -> Map(playTimeIdStr -> seq)
|
||
const dateSeqCounters = new Map()
|
||
const initDateCounters = (dateStr) => {
|
||
if (!dateSeqCounters.has(dateStr)) {
|
||
const ptMap = new Map()
|
||
for (const s of localScheduledSongs.value) {
|
||
if (s.playDate !== dateStr) continue
|
||
const k = String(s.playTimeId || '')
|
||
ptMap.set(k, (ptMap.get(k) || 0) + 1)
|
||
}
|
||
dateSeqCounters.set(dateStr, ptMap)
|
||
}
|
||
return dateSeqCounters.get(dateStr)
|
||
}
|
||
|
||
// 4. 处理歌曲,根据期望日期分配 playDate
|
||
pool.forEach((song, idx) => {
|
||
// 4.1 确定播出日期:优先期望日期,否则用当前 selectedDate
|
||
let targetPlayDate = (song.expectedPlayDate && String(song.expectedPlayDate).trim()) || selectedDate.value
|
||
|
||
// 4.2 确定播出时段:优先期望时段,其次是当前选中的时段视图
|
||
let targetPlayTimeId = null
|
||
if (song.preferredPlayTimeId != null) {
|
||
const matched = (playTimes.value || []).find(pt => String(pt.id) === String(song.preferredPlayTimeId))
|
||
if (matched) targetPlayTimeId = matched.id
|
||
}
|
||
if (targetPlayTimeId == null && filteredPtId != null) {
|
||
targetPlayTimeId = filteredPtId
|
||
}
|
||
|
||
// 4.3 如果处于特定时段视图,且该歌曲的时段不匹配(且非空),则跳过
|
||
if (filteredPtId != null && targetPlayTimeId != null && String(targetPlayTimeId) !== String(filteredPtId)) {
|
||
return
|
||
}
|
||
|
||
// 4.4 检查是否已经存在于已排期列表中
|
||
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song?.id === song.id)
|
||
if (existingIndex !== -1) return
|
||
|
||
// 4.5 获取该日期下的计数器
|
||
const ptSeq = initDateCounters(targetPlayDate)
|
||
const ptKey = String(targetPlayTimeId || '')
|
||
const nextSeq = (ptSeq.get(ptKey) || 0) + 1
|
||
ptSeq.set(ptKey, nextSeq)
|
||
|
||
const newSchedule = {
|
||
id: baseId + idx,
|
||
song: song,
|
||
playDate: targetPlayDate,
|
||
sequence: nextSeq,
|
||
playTimeId: targetPlayTimeId,
|
||
isNew: true,
|
||
isLocalOnly: true
|
||
}
|
||
scheduledSongIds.value.add(song.id)
|
||
localScheduledSongs.value.push(newSchedule)
|
||
count++
|
||
})
|
||
|
||
// 5. 按 playDate + playTime 顺序重排 sequence
|
||
const ptOrder = (id) => {
|
||
if (id == null || id === '') return 1e9
|
||
const pt = (playTimes.value || []).find(p => p.id === id)
|
||
if (!pt || !pt.startTime) return 1e8
|
||
const [h, m] = pt.startTime.split(':').map(Number)
|
||
return h * 60 + (m || 0)
|
||
}
|
||
// 5.1 按日期分组后再排序
|
||
const dateGroups = new Map()
|
||
for (const s of localScheduledSongs.value) {
|
||
const key = String(s.playDate || '')
|
||
if (!dateGroups.has(key)) dateGroups.set(key, [])
|
||
dateGroups.get(key).push(s)
|
||
}
|
||
|
||
const allSorted = []
|
||
const sortedDates = [...dateGroups.keys()].sort()
|
||
for (const d of sortedDates) {
|
||
const group = dateGroups.get(d)
|
||
group.sort((a, b) => {
|
||
const po = ptOrder(a.playTimeId) - ptOrder(b.playTimeId)
|
||
if (po !== 0) return po
|
||
return (a.sequence || 0) - (b.sequence || 0)
|
||
})
|
||
group.forEach((item, idx) => { item.sequence = idx + 1 })
|
||
allSorted.push(...group)
|
||
}
|
||
localScheduledSongs.value = allSorted
|
||
|
||
hasChanges.value = true
|
||
const strategyLabel =
|
||
autoScheduleStrategy.value === 'contributor-count' ? '(按投稿人数优先)' : ''
|
||
const byPref = pool.filter(s => s.preferredPlayTimeId || s.expectedPlayDate).length
|
||
const prefInfo = byPref ? `,其中 ${byPref} 首按期望日期/时段排入` : ''
|
||
showToast(`已自动排入 ${count} 首待排歌曲${strategyLabel}${prefInfo},请注意调整顺序后保存`, 'success')
|
||
}
|
||
|
||
// 构建播放队列
|
||
const confirmBuildPlayQueue = () => {
|
||
if (localScheduledSongs.value.length === 0) {
|
||
showToast('当前播放列表为空,无法构建播放队列', 'warning')
|
||
return
|
||
}
|
||
|
||
confirmDialogTitle.value = '构建播放队列'
|
||
confirmDialogMessage.value = `确定要将当前播放列表中 ${localScheduledSongs.value.length} 首歌曲按顺序加载到播放器吗?这将覆盖现有播放队列。`
|
||
confirmDialogType.value = 'info'
|
||
confirmDialogConfirmText.value = '确认构建'
|
||
confirmAction.value = async () => {
|
||
await buildPlayQueue()
|
||
}
|
||
showConfirmDialog.value = true
|
||
}
|
||
|
||
const buildPlayQueue = async () => {
|
||
try {
|
||
loading.value = true
|
||
|
||
const sorted = [...localScheduledSongs.value].sort((a, b) => {
|
||
const dateA = new Date(a.playDate || 0).getTime()
|
||
const dateB = new Date(b.playDate || 0).getTime()
|
||
if (dateA !== dateB) return dateA - dateB
|
||
const ptA = a.playTimeId || 0
|
||
const ptB = b.playTimeId || 0
|
||
if (ptA !== ptB) return ptA - ptB
|
||
return (a.sequence || 0) - (b.sequence || 0)
|
||
})
|
||
|
||
const playlist: PlayableSong[] = sorted
|
||
.map((s) => s.song)
|
||
.filter((song) => song && song.id)
|
||
.map((song) => ({
|
||
id: song.id,
|
||
title: song.title,
|
||
artist: song.artist,
|
||
cover: song.cover || null,
|
||
musicPlatform: song.musicPlatform || null,
|
||
musicId: song.musicId || null,
|
||
playUrl: song.playUrl || null,
|
||
musicUrl: null
|
||
}))
|
||
|
||
if (playlist.length === 0) {
|
||
showToast('没有可用的歌曲信息', 'warning')
|
||
return
|
||
}
|
||
|
||
if (playlist[0].musicUrl) {
|
||
audioPlayer.playSong(playlist[0], playlist, 0)
|
||
} else {
|
||
try {
|
||
const { getMusicUrl } = await import('~/utils/musicUrl')
|
||
const first = playlist[0]
|
||
if (first.playUrl && first.playUrl.trim()) {
|
||
first.musicUrl = first.playUrl.trim()
|
||
} else if (first.musicPlatform && first.musicId) {
|
||
const url = await getMusicUrl(first.musicPlatform, first.musicId, first.playUrl)
|
||
first.musicUrl = url || null
|
||
}
|
||
audioPlayer.playSong(first, playlist, 0)
|
||
} catch (e) {
|
||
audioPlayer.playSong(playlist[0], playlist, 0)
|
||
}
|
||
}
|
||
|
||
showToast(`已成功构建播放队列,共 ${playlist.length} 首歌曲`, 'success')
|
||
} catch (err) {
|
||
console.error('构建播放队列失败:', err)
|
||
showToast('构建播放队列失败: ' + (err as Error).message, 'error')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const openMoveDateDialog = () => {
|
||
if (hasChanges.value) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification('请先保存当前未发布修改后再执行迁移', 'warning')
|
||
}
|
||
return
|
||
}
|
||
|
||
moveTargetDate.value = selectedDate.value
|
||
showMoveDateDialog.value = true
|
||
}
|
||
|
||
const confirmMoveDate = async () => {
|
||
const targetDate = moveTargetDate.value.trim()
|
||
|
||
const targetDateTime = new Date(`${targetDate}T00:00:00.000Z`)
|
||
if (
|
||
Number.isNaN(targetDateTime.getTime()) ||
|
||
targetDateTime.toISOString().split('T')[0] !== targetDate
|
||
) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification('目标日期无效,请使用 YYYY-MM-DD 格式并确保日期有效', 'error')
|
||
}
|
||
return
|
||
}
|
||
|
||
if (targetDate === selectedDate.value) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification('目标日期不能与当前日期相同', 'warning')
|
||
}
|
||
return
|
||
}
|
||
|
||
const sourceDate = selectedDate.value
|
||
const sourceSchedules = [...publicSchedules.value, ...drafts.value].filter((schedule) => {
|
||
if (!schedule.playDate) return false
|
||
return _fmtLocal(schedule.playDate) === sourceDate
|
||
})
|
||
|
||
if (sourceSchedules.length === 0) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification('当前日期没有可迁移的歌曲', 'warning')
|
||
}
|
||
return
|
||
}
|
||
|
||
confirmDialogTitle.value = '迁移排期日期'
|
||
confirmDialogMessage.value = `确定将 ${sourceDate} 的所有 ${sourceSchedules.length} 首歌曲迁移到 ${targetDate} 吗?歌曲顺序与内容将保持不变。`
|
||
confirmDialogType.value = 'warning'
|
||
confirmDialogConfirmText.value = '确认迁移'
|
||
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
|
||
? `已迁移 ${result.movedCount} 首歌曲到 ${targetDate}`
|
||
: '当前日期没有可迁移的歌曲',
|
||
result?.movedCount > 0 ? 'success' : 'warning'
|
||
)
|
||
}
|
||
} catch (error) {
|
||
console.error('迁移排期日期失败:', error)
|
||
if (window.$showNotification) {
|
||
const backendMessage = error.data?.message || error.data?.statusMessage || error.message
|
||
window.$showNotification(
|
||
'迁移失败: ' + (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', // 只获取草稿
|
||
t: Date.now()
|
||
}
|
||
})
|
||
|
||
drafts.value = response.data?.schedules || []
|
||
console.log('加载草稿列表:', drafts.value.length)
|
||
} catch (error) {
|
||
console.error('加载草稿列表失败:', error)
|
||
// 如果加载失败,设置为空数组避免错误
|
||
drafts.value = []
|
||
}
|
||
}
|
||
|
||
// 刷新草稿列表
|
||
|
||
|
||
const buildDraftSignature = () => {
|
||
const songIds = localScheduledSongs.value.map((s) => s.song?.id).filter(Boolean)
|
||
return JSON.stringify({
|
||
date: selectedDate.value,
|
||
playTimeId: selectedPlayTime.value ? parseInt(selectedPlayTime.value) : null,
|
||
songIds
|
||
})
|
||
}
|
||
|
||
const lastSavedDraftSignature = ref('')
|
||
let autoSaveTimer = null
|
||
|
||
// 保存草稿(无需确认)
|
||
const saveDraft = async (silent = false) => {
|
||
loading.value = true
|
||
|
||
try {
|
||
const playTimeId = selectedPlayTime.value ? parseInt(selectedPlayTime.value) : null
|
||
const songIds = localScheduledSongs.value.map((s) => s.song?.id).filter(Boolean)
|
||
|
||
await $fetch('/api/admin/schedule/draft-bulk', {
|
||
method: 'POST',
|
||
body: {
|
||
playDate: selectedDate.value,
|
||
...(playTimeId ? { playTimeId } : {}),
|
||
songIds
|
||
},
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
hasChanges.value = false
|
||
lastSavedDraftSignature.value = buildDraftSignature()
|
||
await loadData() // 重新加载数据
|
||
|
||
// 确保草稿显示在播放顺序中
|
||
updateLocalScheduledSongs()
|
||
|
||
if (!silent && window.$showNotification) {
|
||
if (localScheduledSongs.value.length > 0) {
|
||
window.$showNotification('排期草稿保存成功!', 'success')
|
||
} else {
|
||
window.$showNotification('所有草稿已删除!', 'success')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('保存草稿失败:', error)
|
||
if (window.$showNotification) {
|
||
window.$showNotification('保存草稿失败: ' + (error.data?.message || error.message), 'error')
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const draftStateKey = computed(() => buildDraftSignature())
|
||
|
||
watch(
|
||
draftStateKey,
|
||
(newVal, oldVal) => {
|
||
if (!oldVal) return
|
||
if (loading.value) return
|
||
if (newVal === lastSavedDraftSignature.value) return
|
||
|
||
hasChanges.value = true
|
||
|
||
if (autoSaveTimer) {
|
||
clearTimeout(autoSaveTimer)
|
||
autoSaveTimer = null
|
||
}
|
||
|
||
autoSaveTimer = setTimeout(async () => {
|
||
if (loading.value) return
|
||
if (!hasChanges.value) return
|
||
if (buildDraftSignature() === lastSavedDraftSignature.value) {
|
||
hasChanges.value = false
|
||
return
|
||
}
|
||
await saveDraft(true)
|
||
}, 2000)
|
||
},
|
||
{ flush: 'post' }
|
||
)
|
||
|
||
// 发布排期(需要确认)
|
||
//
|
||
// ⚠️ 这里**只管发布,绝不删除**。
|
||
//
|
||
// 以前为了「清空某天排期」把删除偷偷挂在发布按钮上:列表为空时点发布 → 弹「删除排期」→
|
||
// 调 bulk-publish 传空数组 → 服务端先删光该日期(含草稿)再插入 0 条。
|
||
// 于是只要**界面上的列表是空的**(数据还没加载回来、时段筛选没匹配上、切换日期后没刷新完…),
|
||
// 点「发布」就等于清空当天 —— 按钮叫发布,干的是删除。删除已经拆到
|
||
// clearTodaySchedule(),发布路径不再有任何破坏性分支。
|
||
const publishSchedule = async () => {
|
||
try {
|
||
if (localScheduledSongs.value.length === 0) {
|
||
showToast('当前列表没有歌曲,没有需要发布的内容', 'info')
|
||
return
|
||
}
|
||
|
||
confirmDialogTitle.value = '发布排期'
|
||
confirmDialogMessage.value = '确定要发布当前排期吗?发布后将立即公示并发送通知。'
|
||
confirmDialogType.value = 'warning'
|
||
confirmDialogConfirmText.value = '发布排期'
|
||
|
||
confirmAction.value = async () => {
|
||
await publishScheduleConfirmed()
|
||
}
|
||
showConfirmDialog.value = true
|
||
} catch (error) {
|
||
console.error('发布排期失败:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清空当天的排期(含草稿)—— 独立的显式操作,跟「发布」彻底分开。
|
||
* 服务端 bulk-publish 收到空 songs 数组时会先删该日期全部排期再插入 0 条(即清空),
|
||
* 所以「删除」这个破坏性动作必须由调用方明确要求,见 bulk-publish.post.ts 里的 confirmClear。
|
||
*/
|
||
const clearTodaySchedule = () => {
|
||
if (localScheduledSongs.value.length === 0) {
|
||
showToast('当天本来就没有排期', 'info')
|
||
return
|
||
}
|
||
|
||
confirmDialogTitle.value = '删除当天排期'
|
||
confirmDialogMessage.value = `确定要删除 ${selectedDate.value} 的全部排期吗?已发布的排期和草稿都会一起删除,此操作不可恢复。`
|
||
confirmDialogType.value = 'danger'
|
||
confirmDialogConfirmText.value = '确认删除'
|
||
|
||
confirmAction.value = async () => {
|
||
loading.value = true
|
||
try {
|
||
await $fetch('/api/admin/schedule/bulk-publish', {
|
||
method: 'POST',
|
||
body: {
|
||
playDate: selectedDate.value,
|
||
playTimeId: selectedPlayTime.value ? parseInt(selectedPlayTime.value) : null,
|
||
songs: [],
|
||
confirmClear: true // 明确表示「我就是要清空」,缺了它服务端会拒(防止误清)
|
||
},
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
hasChanges.value = false
|
||
await loadData()
|
||
updateLocalScheduledSongs()
|
||
|
||
if (window.$showNotification) {
|
||
window.$showNotification('当天排期已删除', 'success')
|
||
}
|
||
} catch (error) {
|
||
console.error('删除当天排期失败:', error)
|
||
if (window.$showNotification) {
|
||
window.$showNotification('删除失败: ' + (error.data?.message || error.message), 'error')
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
showConfirmDialog.value = true
|
||
}
|
||
|
||
// 确认发布排期
|
||
const publishScheduleConfirmed = async () => {
|
||
loading.value = true
|
||
|
||
try {
|
||
// 构建发布数据
|
||
const songsToPublish = localScheduledSongs.value.map((item, index) => ({
|
||
songId: item.song.id,
|
||
sequence: index + 1
|
||
}))
|
||
|
||
// 调用批量发布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('排期已删除!', 'success')
|
||
} else {
|
||
window.$showNotification('排期发布成功,通知已发送!', 'success')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('发布排期失败:', error)
|
||
if (window.$showNotification) {
|
||
window.$showNotification('发布排期失败: ' + (error.data?.message || error.message), 'error')
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 发布单个草稿(需要确认)
|
||
const publishSingleDraft = async (draft) => {
|
||
try {
|
||
confirmDialogTitle.value = '发布草稿'
|
||
confirmDialogMessage.value = `确定要发布草稿《${draft.song.title}》吗?发布后将立即公示并发送通知。`
|
||
confirmDialogType.value = 'warning'
|
||
confirmDialogConfirmText.value = '发布'
|
||
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(`草稿《${draft.song.title}》发布成功,通知已发送!`, 'success')
|
||
}
|
||
} catch (error) {
|
||
console.error('发布单个草稿失败:', error)
|
||
if (window.$showNotification) {
|
||
window.$showNotification('发布草稿失败: ' + (error.data?.message || error.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-blue-500').forEach((el) => {
|
||
// 仅移除通过拖拽添加的高亮,避免移除原本的样式
|
||
if (el.dataset.dragHighlight) {
|
||
el.classList.remove('border-blue-500', 'bg-blue-500/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-blue-500', 'bg-blue-500/10')
|
||
sequenceList.dataset.dragHighlight = 'true'
|
||
} else if (scheduledSong) {
|
||
scheduledSong.classList.add('border-blue-500', 'bg-blue-500/10')
|
||
scheduledSong.dataset.dragHighlight = 'true'
|
||
}
|
||
} else if (touchDragData.value?.type === 'schedule') {
|
||
// 拖拽已排歌曲时,高亮待排区域或其他已排歌曲
|
||
if (draggableSongs) {
|
||
draggableSongs.classList.add('border-blue-500', 'bg-blue-500/10')
|
||
draggableSongs.dataset.dragHighlight = 'true'
|
||
} else if (scheduledSong) {
|
||
scheduledSong.classList.add('border-blue-500', 'bg-blue-500/10')
|
||
scheduledSong.dataset.dragHighlight = 'true'
|
||
}
|
||
}
|
||
}
|
||
|
||
// 清除拖拽位置指示
|
||
const clearDragPosition = () => {
|
||
document.querySelectorAll('.border-blue-500').forEach((el) => {
|
||
if (el.dataset.dragHighlight) {
|
||
el.classList.remove('border-blue-500', 'bg-blue-500/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(),
|
||
song: song,
|
||
playDate: selectedDate.value,
|
||
sequence: insertIndex + 1,
|
||
isNew: true,
|
||
isLocalOnly: true
|
||
}
|
||
|
||
scheduledSongIds.value.add(song.id)
|
||
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)
|
||
}
|
||
|
||
// 重新排序
|
||
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%;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
:deep(.tab-panel-content) {
|
||
padding: 12px !important;
|
||
gap: 10px !important;
|
||
}
|
||
:deep(.section-padding) {
|
||
padding: 12px !important;
|
||
}
|
||
:deep(.toolbar-gap) {
|
||
gap: 6px !important;
|
||
}
|
||
}
|
||
</style>
|