Files
BYW-VoiceHub/app/components/Admin/ExpectedPlayDates.vue
T
Henry_Du 4ce4649aaf
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s
初次提交
2026-09-06 08:19:02 +08:00

541 lines
21 KiB
Vue
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.
<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>