feat: 迁移自定义功能至 1.5.9.9 版本

包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
2026-09-12 19:26:42 +08:00
parent 34f0679117
commit 1106aeea6c
161 changed files with 18963 additions and 27900 deletions
+540
View File
@@ -0,0 +1,540 @@
<template>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h3 class="text-lg font-bold text-zinc-100 flex items-center gap-2">
<CalendarDays class="w-5 h-5 text-blue-400" />
期望播出时段设置
</h3>
<button
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-bold transition-colors"
@click="openAddModal"
>
新增开放日期
</button>
</div>
<div v-if="loading" class="text-center py-10">
<Loader2 class="w-6 h-6 animate-spin mx-auto text-zinc-500" />
</div>
<div v-else-if="dates.length === 0" class="text-center py-10 text-zinc-500 bg-zinc-900/50 rounded-xl border border-zinc-800">
暂未设置任何开放日期
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="date in dates" :key="date.id" class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 flex flex-col gap-3">
<div class="flex justify-between items-start">
<div class="font-bold text-zinc-100 flex items-center gap-2">
{{ date.date }}
<span class="text-xs font-normal text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded">{{ date.weekday }}</span>
</div>
<button @click="openDeleteDialog(date)" class="text-red-400 hover:text-red-300 p-1 transition-colors" title="删除">
<Trash2 class="w-4 h-4" />
</button>
</div>
<div class="flex flex-col gap-2.5 bg-zinc-950/50 p-3 rounded-lg border border-zinc-800/50">
<div class="flex items-center justify-between">
<div class="text-xs text-zinc-500">开放范围</div>
<div class="text-xs font-mono text-blue-400 font-medium break-all text-right ml-4">{{ formatSequences(date.availableSequences) }}</div>
</div>
<div class="flex items-center justify-between">
<div class="text-xs text-zinc-500">序号状态</div>
<div class="text-xs flex gap-2">
<span class="text-emerald-500 bg-emerald-500/10 px-1.5 py-0.5 rounded border border-emerald-500/20">空闲 {{ getAvailableCount(date) }}</span>
<span class="text-zinc-400 bg-zinc-800 px-1.5 py-0.5 rounded border border-zinc-700">共 {{ getTotalCount(date) }}</span>
</div>
</div>
</div>
<!-- 新增详细占用情况列表 -->
<div v-if="date.takenSequences && date.takenSequences.length > 0" class="mt-1 pt-3 border-t border-zinc-800">
<div class="text-xs font-bold text-zinc-500 mb-2 flex items-center justify-between">
<span>已占用明细</span>
<span class="text-red-400">{{ date.takenSequences.length }} 个</span>
</div>
<div class="space-y-1.5 max-h-32 overflow-y-auto pr-1 custom-scrollbar">
<div
v-for="seq in date.takenSequences"
:key="seq"
class="flex items-center gap-2 text-[11px]"
>
<span class="w-5 h-5 rounded flex items-center justify-center bg-red-500/10 text-red-500 font-bold border border-red-500/20 shrink-0">{{ seq }}</span>
<span class="text-zinc-300 truncate" :title="date.takenDetails?.[seq]?.title">{{ date.takenDetails?.[seq]?.title || '未知歌曲' }}</span>
<span class="text-zinc-500 truncate" :title="date.takenDetails?.[seq]?.artist">- {{ date.takenDetails?.[seq]?.artist || '未知歌手' }}</span>
</div>
</div>
</div>
<div v-else class="mt-2 pt-3 border-t border-zinc-800 text-xs text-emerald-500/70 font-bold">
所有序号目前空闲
</div>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-lg p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-zinc-100">新增开放日期</h3>
</div>
<!-- Tabs -->
<div class="flex p-1 bg-zinc-950 rounded-lg mb-6">
<button
@click="activeTab = 'auto'"
:class="['flex-1 py-2 text-xs font-bold rounded-md transition-colors', activeTab === 'auto' ? 'bg-blue-600 text-white' : 'text-zinc-500 hover:text-zinc-300']"
>
批量自动分配
</button>
<button
@click="activeTab = 'manual'; nextTick(() => { setTimeout(() => { setStartSeq(manualFormData.startSeq); setEndSeq(manualFormData.endSeq) }, 100) })"
:class="['flex-1 py-2 text-xs font-bold rounded-md transition-colors', activeTab === 'manual' ? 'bg-blue-600 text-white' : 'text-zinc-500 hover:text-zinc-300']"
>
手动配置单日
</button>
</div>
<!-- 自动分配表单 -->
<div v-if="activeTab === 'auto'" class="space-y-4">
<div>
<label class="block text-xs font-bold text-zinc-500 mb-2">选择播出日期 (可多选)</label>
<AdminMultiDatePicker v-model="autoFormData.dates" />
<p class="text-[10px] text-zinc-600 mt-1">已选择 {{ autoFormData.dates.length }} 天</p>
</div>
<div>
<label class="block text-xs font-bold text-zinc-500 mb-1">总序号数量</label>
<input type="number" min="1" v-model="autoFormData.totalSequences" class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-zinc-200">
<div class="flex items-center gap-2 mt-2">
<button @click="autoFormData.totalSequences = 21" class="text-[10px] px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">21</button>
<button @click="autoFormData.totalSequences = 28" class="text-[10px] px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">28</button>
<button @click="autoFormData.totalSequences = 35" class="text-[10px] px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">35</button>
</div>
</div>
<div class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p class="text-xs text-blue-400 font-bold mb-1">自动分配预览:</p>
<p class="text-[10px] text-blue-300/80">
<span v-if="autoFormData.dates.length === 0">请先选择日期</span>
<span v-else>将会把 1 - {{ autoFormData.totalSequences }} 号平均分配到选中的 {{ autoFormData.dates.length }} 天中。每天约 {{ Math.ceil(autoFormData.totalSequences / autoFormData.dates.length) }} 个序号。</span>
</p>
</div>
<div class="flex gap-3 pt-4">
<button @click="showModal = false" class="flex-1 py-2 bg-zinc-800 text-zinc-300 rounded-lg text-sm font-bold">取消</button>
<button @click="saveAuto" :disabled="saving || autoFormData.dates.length === 0" class="flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-50">批量保存</button>
</div>
</div>
<!-- 手动配置表单 -->
<div v-else class="space-y-4">
<div>
<label class="block text-xs font-bold text-zinc-500 mb-2">选择日期 (单选)</label>
<AdminDatePicker v-model="manualFormData.date" />
</div>
<div>
<label class="block text-xs font-bold text-zinc-500 mb-1">开放的空闲序号</label>
<div class="flex flex-col items-center bg-zinc-950 border border-zinc-800 rounded-xl p-4">
<!-- 模拟 iOS 滚轮 (这里用简化的网格选择代替复杂的滚轮组件以保证稳定性和易用性) -->
<div class="text-sm font-bold text-zinc-400 mb-3 w-full flex justify-between items-center">
<span>选择开放范围</span>
<span class="text-blue-400">{{ manualFormData.startSeq }} - {{ manualFormData.endSeq }}</span>
</div>
<div class="flex gap-4 items-center w-full justify-center py-4">
<div class="flex flex-col items-center flex-1">
<span class="text-[10px] text-zinc-500 mb-2">起始序号</span>
<div ref="startScrollRef" class="relative w-full h-32 bg-zinc-900 border border-zinc-700 rounded-lg overflow-hidden snap-y snap-mandatory hide-scrollbar" style="scroll-behavior: smooth;" @scroll="handleStartScroll">
<div class="absolute top-1/2 left-0 right-0 h-10 -mt-5 bg-blue-500/10 border-y border-blue-500/30 pointer-events-none z-10"></div>
<div class="h-[44px]"></div> <!-- Padding top -->
<div
v-for="n in 100"
:key="'start-'+n"
class="h-10 flex items-center justify-center text-lg font-bold snap-center cursor-pointer transition-colors"
:class="manualFormData.startSeq === n ? 'text-blue-400' : 'text-zinc-500 hover:text-zinc-300'"
@click="setStartSeq(n)"
:data-val="n"
>
{{ n }}
</div>
<div class="h-[44px]"></div> <!-- Padding bottom -->
</div>
</div>
<span class="text-zinc-500 font-bold">至</span>
<div class="flex flex-col items-center flex-1">
<span class="text-[10px] text-zinc-500 mb-2">结束序号</span>
<div ref="endScrollRef" class="relative w-full h-32 bg-zinc-900 border border-zinc-700 rounded-lg overflow-hidden snap-y snap-mandatory hide-scrollbar" style="scroll-behavior: smooth;" @scroll="handleEndScroll">
<div class="absolute top-1/2 left-0 right-0 h-10 -mt-5 bg-blue-500/10 border-y border-blue-500/30 pointer-events-none z-10"></div>
<div class="h-[44px]"></div> <!-- Padding top -->
<div
v-for="n in 100"
:key="'end-'+n"
class="h-10 flex items-center justify-center text-lg font-bold snap-center cursor-pointer transition-colors"
:class="manualFormData.endSeq === n ? 'text-blue-400' : 'text-zinc-500 hover:text-zinc-300'"
@click="setEndSeq(n)"
:data-val="n"
>
{{ n }}
</div>
<div class="h-[44px]"></div> <!-- Padding bottom -->
</div>
</div>
</div>
<!-- 如果有特定跳过的序号,保留文本框以供进阶编辑 -->
<div class="w-full mt-4">
<div
class="flex justify-between items-center text-[10px] text-zinc-500 cursor-pointer py-2 hover:text-zinc-300 transition-colors"
@click="showAdvancedManual = !showAdvancedManual"
>
<span>进阶:自定义跳过某些序号</span>
<span :class="{'rotate-180': showAdvancedManual}" class="transition-transform">▼</span>
</div>
<div v-if="showAdvancedManual" class="mt-2">
<textarea v-model="manualFormData.sequences" rows="2" placeholder="例如: 1,2,3,5 (跳过了4)" class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-xs text-zinc-300 resize-none focus:outline-none focus:border-blue-500"></textarea>
<p class="text-[10px] text-zinc-600 mt-1">手动编辑后将以此处文本框内容为准</p>
</div>
</div>
</div>
<p class="text-[10px] text-zinc-600 mt-2">注意:手动配置会覆盖该日期原有的设置</p>
</div>
<div class="flex gap-3 pt-4">
<button @click="showModal = false" class="flex-1 py-2 bg-zinc-800 text-zinc-300 rounded-lg text-sm font-bold">取消</button>
<button @click="saveManual" :disabled="saving || !manualFormData.date" class="flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-50">保存</button>
</div>
</div>
</div>
</div>
<ConfirmDialog
:show="showDeleteDialog"
title="确认删除"
:message="deleteDialogMessage"
type="danger"
confirm-text="确认删除"
cancel-text="取消"
:loading="deleting"
@confirm="confirmDelete"
@update:show="showDeleteDialog = $event"
/>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, computed } from 'vue'
import { CalendarDays, Trash2, Loader2 } from '@lucide/vue'
import AdminMultiDatePicker from './AdminMultiDatePicker.vue'
import AdminDatePicker from './AdminDatePicker.vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useAuth } from '~/composables/useAuth'
const auth = useAuth()
const dates = ref([])
const loading = ref(true)
const showModal = ref(false)
const saving = ref(false)
const activeTab = ref('auto')
const showAdvancedManual = ref(false)
const showDeleteDialog = ref(false)
const deleteTarget = ref(null)
const deleting = ref(false)
const deleteDialogMessage = computed(() => {
if (!deleteTarget.value) return '确定要删除该开放日期吗?此操作不可撤销。'
const dateStr = deleteTarget.value.date || ''
return `确定要删除开放日期 ${dateStr} 吗?此操作不可撤销。`
})
const formatSequences = (seqStr) => {
if (!seqStr) return '无'
const seqs = seqStr.split(',').map(Number).filter(n => !isNaN(n)).sort((a, b) => a - b)
if (seqs.length === 0) return '无'
const ranges = []
let start = seqs[0]
let prev = seqs[0]
for (let i = 1; i < seqs.length; i++) {
if (seqs[i] === prev + 1) {
prev = seqs[i]
} else {
ranges.push(start === prev ? `${start}` : `${start}-${prev}`)
start = seqs[i]
prev = seqs[i]
}
}
ranges.push(start === prev ? `${start}` : `${start}-${prev}`)
return ranges.join(', ')
}
const getTotalCount = (date) => {
return date.availableSequences ? date.availableSequences.split(',').filter(Boolean).length : 0
}
const getTakenCount = (date) => {
return date.takenSequences ? date.takenSequences.length : 0
}
const getAvailableCount = (date) => {
return Math.max(0, getTotalCount(date) - getTakenCount(date))
}
const startScrollRef = ref(null)
const endScrollRef = ref(null)
let scrollTimeoutStart = null
let scrollTimeoutEnd = null
const autoFormData = ref({
dates: [],
totalSequences: 28
})
const manualFormData = ref({
date: new Date().toISOString().split('T')[0],
startSeq: 1,
endSeq: 28,
sequences: ''
})
const fetchDates = async () => {
loading.value = true
try {
const res = await $fetch('/api/admin/expected-play-dates', {
...auth.getAuthConfig(),
query: { t: Date.now() }
})
dates.value = res
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const setStartSeq = (val) => {
manualFormData.value.startSeq = val
if (startScrollRef.value) {
const el = startScrollRef.value.querySelector(`[data-val="${val}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
const setEndSeq = (val) => {
manualFormData.value.endSeq = val
if (endScrollRef.value) {
const el = endScrollRef.value.querySelector(`[data-val="${val}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
const handleStartScroll = (e) => {
clearTimeout(scrollTimeoutStart)
scrollTimeoutStart = setTimeout(() => {
if (!startScrollRef.value) return
const container = startScrollRef.value
const center = container.scrollTop + container.clientHeight / 2
const items = Array.from(container.querySelectorAll('[data-val]'))
let closest = items[0]
let minDiff = Infinity
items.forEach(item => {
const itemCenter = item.offsetTop + item.clientHeight / 2
const diff = Math.abs(center - itemCenter)
if (diff < minDiff) {
minDiff = diff
closest = item
}
})
if (closest) {
const val = parseInt(closest.getAttribute('data-val'))
manualFormData.value.startSeq = val
}
}, 150)
}
const handleEndScroll = (e) => {
clearTimeout(scrollTimeoutEnd)
scrollTimeoutEnd = setTimeout(() => {
if (!endScrollRef.value) return
const container = endScrollRef.value
const center = container.scrollTop + container.clientHeight / 2
const items = Array.from(container.querySelectorAll('[data-val]'))
let closest = items[0]
let minDiff = Infinity
items.forEach(item => {
const itemCenter = item.offsetTop + item.clientHeight / 2
const diff = Math.abs(center - itemCenter)
if (diff < minDiff) {
minDiff = diff
closest = item
}
})
if (closest) {
const val = parseInt(closest.getAttribute('data-val'))
manualFormData.value.endSeq = val
}
}, 150)
}
const openAddModal = () => {
activeTab.value = 'auto'
autoFormData.value = { dates: [], totalSequences: 28 }
manualFormData.value = { date: new Date().toISOString().split('T')[0], startSeq: 1, endSeq: 28, sequences: '' }
showAdvancedManual.value = false
showModal.value = true
// 延时滚动到对应位置
nextTick(() => {
setTimeout(() => {
setStartSeq(manualFormData.value.startSeq)
setEndSeq(manualFormData.value.endSeq)
}, 100)
})
}
const saveAuto = async () => {
if (autoFormData.value.dates.length === 0 || !autoFormData.value.totalSequences) return
saving.value = true
const selectedDates = [...autoFormData.value.dates].sort()
const totalDays = selectedDates.length
const maxSeq = parseInt(autoFormData.value.totalSequences)
const seqsPerDay = Math.ceil(maxSeq / totalDays)
try {
for (let i = 0; i < selectedDates.length; i++) {
const date = selectedDates[i]
const startSeq = i * seqsPerDay + 1
const endSeq = Math.min((i + 1) * seqsPerDay, maxSeq)
const seqs = []
for (let j = startSeq; j <= endSeq; j++) {
seqs.push(j)
}
if (seqs.length > 0) {
await $fetch('/api/admin/expected-play-dates', {
method: 'POST',
body: {
date: date,
availableSequences: seqs.join(',')
},
...auth.getAuthConfig()
})
}
}
showModal.value = false
fetchDates()
if (window.$showNotification) {
window.$showNotification('批量自动分配成功', 'success')
}
} catch (e) {
if (window.$showNotification) {
window.$showNotification(e.data?.message || '保存失败', 'error')
}
} finally {
saving.value = false
}
}
const saveManual = async () => {
let finalSequences = manualFormData.value.sequences
if (!showAdvancedManual.value || !finalSequences) {
const start = Math.min(manualFormData.value.startSeq, manualFormData.value.endSeq)
const end = Math.max(manualFormData.value.startSeq, manualFormData.value.endSeq)
finalSequences = Array.from({ length: end - start + 1 }, (_, i) => start + i).join(',')
}
if (!manualFormData.value.date || !finalSequences) return
saving.value = true
try {
await $fetch('/api/admin/expected-play-dates', {
method: 'POST',
body: {
date: manualFormData.value.date,
availableSequences: finalSequences
},
...auth.getAuthConfig()
})
showModal.value = false
fetchDates()
if (window.$showNotification) {
window.$showNotification('手动配置成功', 'success')
}
} catch (e) {
if (window.$showNotification) {
window.$showNotification(e.data?.message || '保存失败', 'error')
}
} finally {
saving.value = false
}
}
const deleteDate = async (id) => {
try {
await $fetch(`/api/admin/expected-play-dates/${id}`, {
method: 'DELETE',
...auth.getAuthConfig()
})
fetchDates()
if (window.$showNotification) {
window.$showNotification('删除成功', 'success')
}
} catch (e) {
console.error(e)
if (window.$showNotification) {
window.$showNotification(e.data?.message || '删除失败', 'error')
}
}
}
const openDeleteDialog = (date) => {
deleteTarget.value = date
showDeleteDialog.value = true
}
const confirmDelete = async () => {
if (!deleteTarget.value?.id) return
deleting.value = true
try {
await deleteDate(deleteTarget.value.id)
showDeleteDialog.value = false
deleteTarget.value = null
} finally {
deleting.value = false
}
}
onMounted(fetchDates)
</script>
<style scoped>
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #4a5568;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #718096;
}
</style>