Files
BYW-VoiceHub/patches/0042-feat-schedule-fix-schedule-feat-player-5-localStorag.patch
T

739 lines
28 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
From 153ee41ef07b84f8cfb4ff3905abc0651c3ac4ba Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 08:33:25 +0800
Subject: [PATCH 42/58] =?UTF-8?q?feat(schedule)+fix(schedule)+feat(player)?=
=?UTF-8?q?:=20=E6=8E=92=E6=9C=9F=E7=AE=A1=E7=90=865=E9=A1=B9=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD=20-=20=E7=B4=A7=E5=87=91=E5=B8=83=E5=B1=80/=E6=8A=95?=
=?UTF-8?q?=E7=A8=BF=E4=BA=BA=E4=BC=98=E5=85=88=E7=AE=97=E6=B3=95/localSto?=
=?UTF-8?q?rage=E6=8C=81=E4=B9=85=E5=8C=96/=E5=A4=87=E9=80=89=E6=B1=A0?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D/=E6=92=AD=E6=94=BE=E9=98=9F=E5=88=97?=
=?UTF-8?q?=E6=8C=89=E9=92=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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
---
app/components/Admin/ScheduleManager.vue | 338 ++++++++++++++++---
app/composables/useSongs.ts | 9 +-
server/api/admin/schedule.post.ts | 23 +-
server/api/admin/schedule/draft-bulk.post.ts | 17 +-
server/api/songs/index.get.ts | 35 +-
5 files changed, 364 insertions(+), 58 deletions(-)
diff --git a/app/components/Admin/ScheduleManager.vue b/app/components/Admin/ScheduleManager.vue
index 2c4d780..c36f17d 100644
--- a/app/components/Admin/ScheduleManager.vue
+++ b/app/components/Admin/ScheduleManager.vue
@@ -1,23 +1,35 @@
<template>
- <div class="space-y-6 pb-24 md:pb-8">
- <!-- 日期选择器 -->
- <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"
- >
- <CustomSelect
- :model-value="selectedPlayTime"
- label="播出时段"
- :options="playTimeOptions"
- class-name="w-full"
- @update:model-value="handlePlayTimeSelect"
+ <div class="space-y-5 pb-24 md:pb-6">
+ <!-- 日期与排期配置区 -->
+ <div class="space-y-3">
+ <AdminDatePicker
+ :modelValue="selectedDate"
+ @update:modelValue="handleDateSelect"
/>
+
+ <!-- 播出时段与自动排期配置 -->
+ <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>
<!-- 加载状态 -->
@@ -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
}
- // 按照期望序号排序,如果有的话
- expectedSongs.sort((a, b) => (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999))
+ 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: 640px) {
+ :deep(.tab-panel-content) {
+ padding: 12px !important;
+ gap: 10px !important;
+ }
+ :deep(.section-padding) {
+ padding: 12px !important;
+ }
+ :deep(.toolbar-gap) {
+ gap: 6px !important;
+ }
+}
</style>
diff --git a/app/composables/useSongs.ts b/app/composables/useSongs.ts
index a0ed42c..9c3f1cd 100644
--- a/app/composables/useSongs.ts
+++ b/app/composables/useSongs.ts
@@ -75,7 +75,8 @@ export const useSongs = () => {
silent = false,
semester?: string,
forceRefresh = false,
- bypassCache = false
+ bypassCache = false,
+ usePool = false
) => {
if (!silent) {
loading.value = true
@@ -88,21 +89,21 @@ export const useSongs = () => {
const response = await dedup.dedupedRequest(
'songs',
async () => {
- // 构建URL参数
const params = new URLSearchParams()
if (semester) {
params.append('semester', semester)
}
- // 只有当 bypassCache 为 true 时才添加 bypass_cache 参数
if (bypassCache) {
params.append('bypass_cache', 'true')
}
+ if (usePool) {
+ params.append('use_pool', 'true')
+ }
if (forceRefresh) {
params.append('t', Date.now().toString())
}
const url = `/api/songs${params.toString() ? '?' + params.toString() : ''}`
- // API请求
return await $fetch(url, {
...getAuthConfig()
})
diff --git a/server/api/admin/schedule.post.ts b/server/api/admin/schedule.post.ts
index 16d0e40..44f6905 100644
--- a/server/api/admin/schedule.post.ts
+++ b/server/api/admin/schedule.post.ts
@@ -1,5 +1,13 @@
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 { createSongSelectedNotification } from '../../services/notificationService'
import { cacheService } from '~~/server/services/cacheService'
@@ -126,6 +134,19 @@ export default defineEventHandler(async (event) => {
// 获取客户端IP地址
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) {
await createSongSelectedNotification(
diff --git a/server/api/admin/schedule/draft-bulk.post.ts b/server/api/admin/schedule/draft-bulk.post.ts
index fb63559..980fa22 100644
--- a/server/api/admin/schedule/draft-bulk.post.ts
+++ b/server/api/admin/schedule/draft-bulk.post.ts
@@ -1,5 +1,5 @@
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 { 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 })
+ 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 }
})
diff --git a/server/api/songs/index.get.ts b/server/api/songs/index.get.ts
index cb817f6..a8274b4 100644
--- a/server/api/songs/index.get.ts
+++ b/server/api/songs/index.get.ts
@@ -8,9 +8,10 @@ import {
songs,
systemSettings,
users,
- votes
+ votes,
+ scheduleSongPool
} 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 { formatDateTime } from '~/utils/timeUtils'
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 sortOrder = (query.sortOrder as string) || 'desc'
const bypassCache = query.bypass_cache === 'true'
+ const usePool = query.use_pool === 'true' || query.usePool === 'true'
// 获取用户身份
const user = event.context.user || null
@@ -203,6 +205,35 @@ export default defineEventHandler(async (event) => {
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
// 查询歌曲总数
--
2.45.1.windows.1