feat(schedule)+fix(schedule)+feat(player): 排期管理5项功能 - 紧凑布局/投稿人优先算法/localStorage持久化/备选池修复/播放队列按钮

* fix+style(schedule): ScheduleManager 标签页紧凑布局,头部 min-height 防抖,内容区统间距与移动端适配
* feat(schedule): 新增自动排期策略「按投稿人数优先覆盖」,按 requester+collaborators 数排序分配好时段
* feat(schedule): localStorage 键 voicehub:schedule:auto-config 持久化策略/时段/学期/过滤等7项,onMounted 恢复,watch debounce 500ms 保存
* fix(schedule): songs/index.get.ts 新增 use_pool 查询参数,备选池过滤条件 status=PENDING + song.played=false + song未在任何排期中;ScheduleManager/draft-bulk 创建排期时将池记录 UPDATE 为 ASSIGNED
* feat(player): ScheduleManager 工具栏新增「构建播放队列」按钮,按 playDate+playTimeId+sequence 排序后调用 useAudioPlayer.playSong 加载播放列表,支持 confirm 确认与 toast 反馈
* refactor(useSongs): fetchSongs 增加第5参数 usePool,透传 use_pool=true 到 /api/songs
This commit is contained in:
2026-09-12 08:33:25 +08:00
parent 4d39afd23f
commit 153ee41ef0
5 changed files with 364 additions and 58 deletions
+288 -50
View File
@@ -1,23 +1,35 @@
<template> <template>
<div class="space-y-6 pb-24 md:pb-8"> <div class="space-y-5 pb-24 md:pb-6">
<!-- 日期选择器 --> <!-- 日期与排期配置区 -->
<AdminDatePicker <div class="space-y-3">
:modelValue="selectedDate" <AdminDatePicker
@update:modelValue="handleDateSelect" :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"
>
<CustomSelect
:model-value="selectedPlayTime"
label="播出时段"
:options="playTimeOptions"
class-name="w-full"
@update:model-value="handlePlayTimeSelect"
/> />
<!-- 播出时段与自动排期配置 -->
<div
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="播出时段"
:options="playTimeOptions"
class-name="w-full"
@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>
<!-- 加载状态 --> <!-- 加载状态 -->
@@ -27,7 +39,7 @@
<div v-else> <div v-else>
<div <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 <button
:class="[ :class="[
@@ -61,24 +73,24 @@
</button> </button>
</div> </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 <div
v-show="mobileTab === 'pending' || isDesktop" v-show="mobileTab === 'pending' || isDesktop"
:class="[ :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' mobileTab === 'scheduled' ? 'hidden lg:flex' : 'flex'
]" ]"
@dragover.prevent="handleDraggableDragOver" @dragover.prevent="handleDraggableDragOver"
@dragleave="handleDraggableDragLeave" @dragleave="handleDraggableDragLeave"
@drop.stop.prevent="handleReturnToDraggable" @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 class="hidden lg:block text-lg font-black tracking-tight text-zinc-100 uppercase">
待排歌曲 待排歌曲
</h3> </h3>
<div <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 <button
v-for="tab in [ v-for="tab in [
@@ -88,7 +100,7 @@
]" ]"
:key="tab.id" :key="tab.id"
:class="[ :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 activeTab === tab.id
? 'bg-zinc-800 text-blue-400 shadow-md border border-blue-500/20' ? 'bg-zinc-800 text-blue-400 shadow-md border border-blue-500/20'
: 'text-zinc-600 hover:text-zinc-400' : '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="bg-zinc-900/40 border border-zinc-800 rounded-2xl shadow-xl">
<div <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" @click="mobileFiltersOpen = !mobileFiltersOpen"
> >
<div class="flex items-center gap-2 text-zinc-400"> <div class="flex items-center gap-2 text-zinc-400">
@@ -120,7 +132,7 @@
<div <div
v-show="mobileFiltersOpen || isDesktop" 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"> <div class="relative">
<Search <Search
@@ -323,18 +335,18 @@
<div <div
v-show="mobileTab === 'scheduled' || isDesktop" v-show="mobileTab === 'scheduled' || isDesktop"
:class="[ :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' mobileTab === 'pending' ? 'hidden lg:flex' : 'flex'
]" ]"
> >
<div <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> <h3 class="text-lg font-black tracking-tight text-zinc-100 uppercase">播放顺序</h3>
<div <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 <button
:disabled=" :disabled="
!hasChanges && localScheduledSongs.length === 0 && !hasUnpublishedDrafts !hasChanges && localScheduledSongs.length === 0 && !hasUnpublishedDrafts
@@ -381,6 +393,18 @@
>全部已播放</span >全部已播放</span
> >
</button> </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 <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" 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" @click="openMoveDateDialog"
@@ -394,7 +418,7 @@
</div> </div>
<div class="h-6 w-[1px] bg-zinc-800 mx-1" /> <div class="h-6 w-[1px] bg-zinc-800 mx-1" />
<button <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="一键将当前待排歌曲自动排入" title="一键将当前待排歌曲自动排入"
@click="autoScheduleExpected" @click="autoScheduleExpected"
> >
@@ -402,14 +426,14 @@
</button> </button>
<button <button
:disabled="!canPublish" :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" @click="publishSchedule"
> >
<Send class="w-3 h-3" /> 发布排期 <Send class="w-3 h-3" /> 发布排期
</button> </button>
<button <button
:disabled="!hasChanges" :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" @click="saveSequence"
> >
<FileBadge class="w-3.5 h-3.5" /> 保存并发布 <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 CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import LoadingState from '~/components/UI/Common/LoadingState.vue' import LoadingState from '~/components/UI/Common/LoadingState.vue'
import { useSongPlayer } from '~/composables/useSongPlayer' import { useSongPlayer } from '~/composables/useSongPlayer'
import { useAudioPlayer, type PlayableSong } from '~/composables/useAudioPlayer'
import { useToast } from '~/composables/useToast'
import { convertToHttps } from '~/utils/url' 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 showCardGenerator = ref(false)
const selectedDate = ref(new Date().toISOString().split('T')[0]) const selectedDate = ref(new Date().toISOString().split('T')[0])
const loading = ref(false) const loading = ref(false)
const songSortOption = ref('votes-desc') const songSortOption = ref<AutoConfig['songSortOption']>('votes-desc')
const hasChanges = ref(false) const hasChanges = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const selectedGrade = ref('全部') const selectedGrade = ref('全部')
const activeTab = ref('normal') const activeTab = ref<AutoConfig['activeTab']>('normal')
const mobileTab = ref('pending') const mobileTab = ref('pending')
const mobileFiltersOpen = ref(false) const mobileFiltersOpen = ref(false)
// 音频播放器 const autoScheduleStrategy = ref<AutoScheduleStrategy>('default')
const scheduleStrategyOptions = [
{ label: '默认策略(按期望序号)', value: 'default' },
{ label: '按投稿人数优先覆盖', value: 'contributor-count' }
]
// 音频播放器与 Toast
const { playSong } = useSongPlayer() const { playSong } = useSongPlayer()
const audioPlayer = useAudioPlayer()
const { showToast } = useToast()
// 确认对话框相关 // 确认对话框相关
const showConfirmDialog = ref(false) const showConfirmDialog = ref(false)
@@ -983,6 +1041,62 @@ const sortOptions = [
{ label: '热度最低', value: 'votes-asc' } { 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 availableSemesters = ref([])
const selectedSemester = ref('') const selectedSemester = ref('')
@@ -1218,22 +1332,33 @@ onMounted(async () => {
auth = useAuth() auth = useAuth()
semesterService = useSemesters() semesterService = useSemesters()
// 初始化窗口大小检测
checkWindowSize() checkWindowSize()
window.addEventListener('resize', 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() await loadSemesters()
if (savedConfig.selectedSemester && !selectedSemester.value) {
selectedSemester.value = savedConfig.selectedSemester
}
await loadData() await loadData()
refreshInterval = setInterval(() => { refreshInterval = setInterval(() => {
if (!hasChanges.value && !loading.value) { if (!hasChanges.value && !loading.value) {
loadData(true) // silent load if possible, but loadData might set loading=true loadData(true)
} }
}, 10000) }, 10000)
}) })
// 清理事件监听器
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload) window.removeEventListener('beforeunload', handleBeforeUnload)
@@ -1249,8 +1374,27 @@ onUnmounted(() => {
if (refreshInterval) { if (refreshInterval) {
clearInterval(refreshInterval) clearInterval(refreshInterval)
} }
if (configSaveTimer) {
clearTimeout(configSaveTimer)
configSaveTimer = null
}
}) })
watch(
[
autoScheduleStrategy,
selectedPlayTime,
selectedGrade,
songSortOption,
activeTab,
searchQuery,
selectedSemester
],
() => {
debounceSaveAutoConfig()
}
)
// 监听日期变化 // 监听日期变化
watch(selectedDate, async () => { watch(selectedDate, async () => {
await loadData() await loadData()
@@ -1811,14 +1955,21 @@ const autoScheduleExpected = () => {
const expectedSongs = [...filteredUnscheduledSongs.value] const expectedSongs = [...filteredUnscheduledSongs.value]
if (expectedSongs.length === 0) { if (expectedSongs.length === 0) {
if (window.$showNotification) { showToast('当前没有待排期的歌曲', 'info')
window.$showNotification('当前没有待排期的歌曲', 'info')
}
return return
} }
// 按照期望序号排序,如果有的话 if (autoScheduleStrategy.value === 'contributor-count') {
expectedSongs.sort((a, b) => (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999)) 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() const baseId = Date.now()
expectedSongs.forEach((song, idx) => { expectedSongs.forEach((song, idx) => {
@@ -1840,14 +1991,90 @@ const autoScheduleExpected = () => {
count++ count++
}) })
// 重新生成 sequence
localScheduledSongs.value.forEach((item, idx) => { localScheduledSongs.value.forEach((item, idx) => {
item.sequence = idx + 1 item.sequence = idx + 1
}) })
hasChanges.value = true hasChanges.value = true
if (window.$showNotification) { const strategyLabel =
window.$showNotification(`已自动排入 ${count} 首待排歌曲,请注意调整顺序后保存`, 'success') 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> </script>
<style scoped> <style scoped>
/* 隐藏滚动条但保留功能 */
.scrollbar-hide::-webkit-scrollbar { .scrollbar-hide::-webkit-scrollbar {
display: none; display: none;
} }
@@ -2470,7 +2696,6 @@ const handleTouchReturnToDraggable = async () => {
scrollbar-width: none; scrollbar-width: none;
} }
/* 列表过渡动画 */
.schedule-list-move, .schedule-list-move,
.schedule-list-enter-active, .schedule-list-enter-active,
.schedule-list-leave-active { .schedule-list-leave-active {
@@ -2487,4 +2712,17 @@ const handleTouchReturnToDraggable = async () => {
position: absolute; position: absolute;
width: 100%; 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> </style>
+5 -4
View File
@@ -75,7 +75,8 @@ export const useSongs = () => {
silent = false, silent = false,
semester?: string, semester?: string,
forceRefresh = false, forceRefresh = false,
bypassCache = false bypassCache = false,
usePool = false
) => { ) => {
if (!silent) { if (!silent) {
loading.value = true loading.value = true
@@ -88,21 +89,21 @@ export const useSongs = () => {
const response = await dedup.dedupedRequest( const response = await dedup.dedupedRequest(
'songs', 'songs',
async () => { async () => {
// 构建URL参数
const params = new URLSearchParams() const params = new URLSearchParams()
if (semester) { if (semester) {
params.append('semester', semester) params.append('semester', semester)
} }
// 只有当 bypassCache 为 true 时才添加 bypass_cache 参数
if (bypassCache) { if (bypassCache) {
params.append('bypass_cache', 'true') params.append('bypass_cache', 'true')
} }
if (usePool) {
params.append('use_pool', 'true')
}
if (forceRefresh) { if (forceRefresh) {
params.append('t', Date.now().toString()) params.append('t', Date.now().toString())
} }
const url = `/api/songs${params.toString() ? '?' + params.toString() : ''}` const url = `/api/songs${params.toString() ? '?' + params.toString() : ''}`
// API请求
return await $fetch(url, { return await $fetch(url, {
...getAuthConfig() ...getAuthConfig()
}) })
+22 -1
View File
@@ -1,5 +1,13 @@
import { db } from '~/drizzle/db' import { db } from '~/drizzle/db'
import { playTimes, schedules, songs, users, votes, songReplayRequests } from '~/drizzle/schema' import {
playTimes,
schedules,
songs,
users,
votes,
songReplayRequests,
scheduleSongPool
} from '~/drizzle/schema'
import { and, asc, count, desc, eq, gte, lte } from 'drizzle-orm' import { and, asc, count, desc, eq, gte, lte } from 'drizzle-orm'
import { createSongSelectedNotification } from '../../services/notificationService' import { createSongSelectedNotification } from '../../services/notificationService'
import { cacheService } from '~~/server/services/cacheService' import { cacheService } from '~~/server/services/cacheService'
@@ -126,6 +134,19 @@ export default defineEventHandler(async (event) => {
// 获取客户端IP地址 // 获取客户端IP地址
const clientIP = getClientIP(event) const clientIP = getClientIP(event)
await tx
.update(scheduleSongPool)
.set({
status: 'ASSIGNED',
targetPlayDate: playDate
})
.where(
and(
eq(scheduleSongPool.songId, body.songId),
eq(scheduleSongPool.status, 'PENDING')
)
)
// 只有在非草稿模式下才创建通知 // 只有在非草稿模式下才创建通知
if (!isDraft) { if (!isDraft) {
await createSongSelectedNotification( await createSongSelectedNotification(
+16 -1
View File
@@ -1,5 +1,5 @@
import { db } from '~/drizzle/db' import { db } from '~/drizzle/db'
import { schedules, songReplayRequests } from '~/drizzle/schema' import { schedules, songReplayRequests, scheduleSongPool } from '~/drizzle/schema'
import { and, eq, gte, inArray, lte } from 'drizzle-orm' import { and, eq, gte, inArray, lte } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService' import { cacheService } from '~~/server/services/cacheService'
@@ -80,6 +80,21 @@ export default defineEventHandler(async (event) => {
const inserted = await tx.insert(schedules).values(rows).returning({ id: schedules.id }) const inserted = await tx.insert(schedules).values(rows).returning({ id: schedules.id })
if (songIds.length > 0) {
await tx
.update(scheduleSongPool)
.set({
status: 'ASSIGNED',
targetPlayDate: startOfDay
})
.where(
and(
inArray(scheduleSongPool.songId, songIds),
eq(scheduleSongPool.status, 'PENDING')
)
)
}
return { deletedCount: existingSchedules.length, insertedCount: inserted.length } return { deletedCount: existingSchedules.length, insertedCount: inserted.length }
}) })
+33 -2
View File
@@ -8,9 +8,10 @@ import {
songs, songs,
systemSettings, systemSettings,
users, users,
votes votes,
scheduleSongPool
} from '~/drizzle/schema' } from '~/drizzle/schema'
import { and, asc, count, desc, eq, inArray, like, or } from 'drizzle-orm' import { and, asc, count, desc, eq, inArray, like, or, isNull, notInArray } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService' import { cacheService } from '~~/server/services/cacheService'
import { formatDateTime } from '~/utils/timeUtils' import { formatDateTime } from '~/utils/timeUtils'
import { maskSongsInfo, MaskableSong, MaskableUser } from '~~/server/utils/studentMask' import { maskSongsInfo, MaskableSong, MaskableUser } from '~~/server/utils/studentMask'
@@ -67,6 +68,7 @@ export default defineEventHandler(async (event) => {
const sortBy = (query.sortBy as string) || 'createdAt' const sortBy = (query.sortBy as string) || 'createdAt'
const sortOrder = (query.sortOrder as string) || 'desc' const sortOrder = (query.sortOrder as string) || 'desc'
const bypassCache = query.bypass_cache === 'true' const bypassCache = query.bypass_cache === 'true'
const usePool = query.use_pool === 'true' || query.usePool === 'true'
// 获取用户身份 // 获取用户身份
const user = event.context.user || null const user = event.context.user || null
@@ -203,6 +205,35 @@ export default defineEventHandler(async (event) => {
conditions.push(eq(songs.requesterId, user.id)) conditions.push(eq(songs.requesterId, user.id))
} }
if (usePool) {
const poolRows = await db
.select({ songId: scheduleSongPool.songId })
.from(scheduleSongPool)
.where(
and(
eq(scheduleSongPool.status, 'PENDING'),
isNull(scheduleSongPool.targetPlayDate)
)
)
.groupBy(scheduleSongPool.songId)
const allScheduledSongIdsQuery = await db
.select({ songId: schedules.songId })
.from(schedules)
const scheduledIdsSet = new Set(allScheduledSongIdsQuery.map((r) => r.songId))
const candidateFromPool = new Set(
poolRows.map((r) => r.songId).filter((id) => !scheduledIdsSet.has(id))
)
if (candidateFromPool.size === 0) {
conditions.push(eq(songs.id, -1))
} else {
conditions.push(inArray(songs.id, Array.from(candidateFromPool)))
}
conditions.push(eq(songs.played, false))
}
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
// 查询歌曲总数 // 查询歌曲总数