@@ -1,16 +1,17 @@
< template >
< div class = "space-y-6 pb-24 md:pb-8 " >
<!-- 日期选择器 -- >
< div class = "space-y-5 pb-24 md:pb-6 " >
<!-- 日期与排期配置区 -- >
< div class = "space-y-3" >
< AdminDatePicker
:modelValue = "selectedDate"
@ update :modelValue = "handleDateSelect"
/ >
<!-- 播出时段选择器 ( 如果启用 ) -- >
<!-- 播出时段与自动排期配置 -- >
< div
v-if = "playTimeEnabled"
class = "flex items-center gap-3 bg-zinc-900/30 border border-zinc-800 rounded-lg p-3"
class = "grid grid-cols-1 md:grid-cols-2 gap-3 bg-zinc-900/30 border border-zinc-800 rounded-xl p-3 md:p-4"
>
< div v-if = "playTimeEnabled" >
< CustomSelect
:model-value = "selectedPlayTime"
label = "播出时段"
@@ -19,6 +20,17 @@
@ update :model-value = "handlePlayTimeSelect"
/ >
< / div >
< div : class = "playTimeEnabled ? '' : 'md:col-span-2'" >
< CustomSelect
:model-value = "autoScheduleStrategy"
label = "自动排期策略"
:options = "scheduleStrategyOptions"
class -name = " w -full "
@ update :model-value = "handleStrategyChange"
/ >
< / div >
< / div >
< / div >
<!-- 加载状态 -- >
< div v-if = "loading" class="flex flex-col items-center justify-center py-20 min-h-[60vh]" >
@@ -27,7 +39,7 @@
< div v-else >
< div
class = "lg:hidden sticky -top-4 -mx-4 -mt-4 z-20 flex p-1 bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800 shadow-xl mb-4 pt-4"
class = "lg:hidden sticky -top-4 -mx-4 -mt-4 z-20 flex gap-2 p-2 bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800 shadow-xl mb-4 pt-4 min-h-[64px] "
>
< button
: class = "[
@@ -61,24 +73,24 @@
< / button >
< / div >
< div class = "grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-0" >
< 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 space-y-2 ',
'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 items-center justify-between px-1 " >
< 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-100 uppercase" >
待排歌曲
< / h3 >
< div
class = "flex w-full lg:w-auto gap-1 p-1 bg-zinc-900/50 rounded-xl border border-zinc-800"
class = "flex w-full lg:w-auto gap-2 p-1.5 bg-zinc-900/50 rounded-xl border border-zinc-800"
>
< button
v-for ="tab in [
@@ -88,7 +100,7 @@
]"
:key="tab.id"
:class="[
'flex-1 lg:flex-none px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
'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-800 text-blue-400 shadow-md border border-blue-500/20'
: 'text-zinc-600 hover:text-zinc-400'
@@ -103,7 +115,7 @@
<!-- 筛选区 - 移动端折叠 -->
<div class="bg-zinc-900/40 border border-zinc-800 rounded-2xl shadow-xl">
<div
class="p-4 flex items-center justify-between lg:hidden border-b border-zinc-800/50 rounded-t-2xl"
class="p-3 md:p- 4 flex items-center justify-between lg:hidden border-b border-zinc-800/50 rounded-t-2xl"
@click="mobileFiltersOpen = !mobileFiltersOpen"
>
<div class="flex items-center gap-2 text-zinc-400">
@@ -120,7 +132,7 @@
<div
v-show="mobileFiltersOpen || isDesktop"
class="p-3 space-y-2 transition-all duration-300 ease-in-out rounded-b-2xl"
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
@@ -323,18 +335,18 @@
<div
v-show="mobileTab === 'scheduled' || isDesktop"
:class="[
'lg:col-span-8 flex flex-col space-y -4',
'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-4 px-1 "
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-100 uppercase">播放顺序</h3>
<div
class="flex flex-wrap items-center gap-2 p-1.5 bg-zinc-900/50 border border-zinc-800/50 rounded-2xl"
class="flex flex-wrap items-center gap-1.5 md: gap-2 p-1.5 bg-zinc-900/50 border border-zinc-800/50 rounded-2xl"
>
<div class="flex gap-1">
<div class="flex gap-1 md:gap-1.5 ">
<button
:disabled="
!hasChanges && localScheduledSongs.length === 0 && !hasUnpublishedDrafts
@@ -381,6 +393,18 @@
>全部已播放</span
>
</button>
<button
:disabled="localScheduledSongs.length === 0"
class="p-2 bg-zinc-950 border border-zinc-800 hover:bg-zinc-800 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-800 text-[9px] text-zinc-300 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-700"
>构建播放队列</span
>
</button>
<button
class="p-2 bg-zinc-950 border border-zinc-800 hover:bg-zinc-800 text-zinc-500 hover:text-purple-400 rounded-xl transition-all group relative"
@click="openMoveDateDialog"
@@ -394,7 +418,7 @@
</div>
<div class="h-6 w-[1px] bg-zinc-800 mx-1" />
<button
class="flex items-center gap-1.5 px-3 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-500 text-[10px] font-black rounded-xl border border-yellow-500/20 transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50"
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"
>
@@ -402,14 +426,14 @@
</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] 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"
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] 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"
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" /> 保存并发布
@@ -838,22 +862,56 @@ 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 selectedDate = ref(new Date().toISOString().split('T')[0])
const loading = ref(false)
const songSortOption = ref('votes-desc')
const songSortOption = ref<AutoConfig['songSortOption']> ('votes-desc')
const hasChanges = ref(false)
const searchQuery = ref('')
const selectedGrade = ref('全部')
const activeTab = ref('normal')
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)
@@ -983,6 +1041,62 @@ const sortOptions = [
{ 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('')
@@ -1218,22 +1332,33 @@ onMounted(async () => {
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) // silent load if possible, but loadData might set loading=true
loadData(true)
}
}, 10000)
})
// 清理事件监听器
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
@@ -1249,8 +1374,27 @@ onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
if (configSaveTimer) {
clearTimeout(configSaveTimer)
configSaveTimer = null
}
})
watch(
[
autoScheduleStrategy,
selectedPlayTime,
selectedGrade,
songSortOption,
activeTab,
searchQuery,
selectedSemester
],
() => {
debounceSaveAutoConfig()
}
)
// 监听日期变化
watch(selectedDate, async () => {
await loadData()
@@ -1811,14 +1955,21 @@ const autoScheduleExpected = () => {
const expectedSongs = [ ... filteredUnscheduledSongs . value ]
if ( expectedSongs . length === 0 ) {
if ( window . $showNotification ) {
window . $showNotification ( '当前没有待排期的歌曲' , 'info' )
}
showToast ( '当前没有待排期的歌曲' , 'info' )
return
}
// 按照期望序号排序,如果有的话
if ( autoScheduleStrategy . value === 'contributor-count' ) {
expectedSongs . 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 {
expectedSongs . sort ( ( a , b ) => ( a . expectedPlaySequence || 9999 ) - ( b . expectedPlaySequence || 9999 ) )
}
const baseId = Date . now ( )
expectedSongs . forEach ( ( song , idx ) => {
@@ -1840,14 +1991,90 @@ const autoScheduleExpected = () => {
count ++
} )
// 重新生成 sequence
localScheduledSongs . value . forEach ( ( item , idx ) => {
item . sequence = idx + 1
} )
hasChanges . value = true
if ( window . $showNotification ) {
window . $showNotification ( ` 已自动排入 ${ count } 首待排歌曲,请注意调整顺序后保存 ` , 'success' )
const strategyLabel =
autoScheduleStrategy . value === 'contributor-count' ? '(按投稿人数优先)' : ''
showToast ( ` 已自动排入 ${ count } 首待排歌曲 ${ strategyLabel } ,请注意调整顺序后保存 ` , '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
}
}
@@ -2461,7 +2688,6 @@ const handleTouchReturnToDraggable = async () => {
< / script >
< style scoped >
/* 隐藏滚动条但保留功能 */
. scrollbar - hide : : - webkit - scrollbar {
display : none ;
}
@@ -2470,7 +2696,6 @@ const handleTouchReturnToDraggable = async () => {
scrollbar - width : none ;
}
/* 列表过渡动画 */
. schedule - list - move ,
. schedule - list - enter - active ,
. schedule - list - leave - active {
@@ -2487,4 +2712,17 @@ const handleTouchReturnToDraggable = async () => {
position : absolute ;
width : 100 % ;
}
@ media ( max - width : 640 px ) {
: deep ( . tab - panel - content ) {
padding : 12 px ! important ;
gap : 10 px ! important ;
}
: deep ( . section - padding ) {
padding : 12 px ! important ;
}
: deep ( . toolbar - gap ) {
gap : 6 px ! important ;
}
}
< / style >