739 lines
28 KiB
Diff
739 lines
28 KiB
Diff
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
|
||
|