Files
BYW-VoiceHub/app/components/Admin/AdminMultiDatePicker.vue
T
Henry_Du ac4727551c
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 7m34s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 7m54s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
feat(admin): 图片歌单改导入酷狗+自动排入按期望时段+日期滚动修复
1. 菜单重命名: 图片歌单 -> 导入酷狗 (Sidebar/Dashboard/教程 + 页面标题)
2. PlaylistCardPage(导入酷狗) 新增:
   - 粘贴酷狗歌单文本解析(支持序号/横杠/括号格式)
   - 预览区歌名与歌手名完整显示(去除truncate, 支持换行展示)
   - 选日期+选播出时段, 一键将解析后的歌曲(没找到则自动创建)生成为该日期的排期
   - 批量保存后展示状态徽章(匹配/创建/失败)
3. PlaylistCardGenerator 画布:
   - 去除 substring(0,12)/substring(0,10) 截断, 改用 fitText 按 canvas 宽度逐字符换行切分
   - canvas 高度根据实际行数动态计算, 最多支持 200 首
   - 中文字体堆栈加入 PingFang SC/Microsoft YaHei
4. ScheduleManager.autoScheduleExpected:
   - 优先按歌曲自己的 preferredPlayTimeId(期望播出时段) 分组排入
   - 未指定才 fallback 到顶部当前选中时段
   - 分时段各自维护 sequence, 最后按时段 startTime 升序+分序 合并重排
   - 顶部过滤了单时段时, 只排入匹配该时段的歌曲(含未指定fallback)
5. 日期滚动修复:
   - AdminDatePicker / AdminMultiDatePicker scrollToDateElement 弃用 scrollIntoView
   - 改用精确的 offsetLeft - (clientWidth-offsetWidth)/2 计算 scrollTo left 值, 避免受父级滚动容器/左箭头影响
2026-09-13 12:22:28 +08:00

457 lines
16 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="relative bg-zinc-900/50 border border-zinc-800/50 rounded-2xl p-1 overflow-hidden">
<!-- 横向日期选择条 -->
<div class="flex items-center" @touchstart.stop>
<button
:disabled="isFirstDateVisible"
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors shrink-0"
@click="scrollDates('left')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="15,18 9,12 15,6" /></svg>
</button>
<div
ref="dateSelector"
class="flex-1 flex overflow-x-auto scrollbar-hide gap-2 px-2 py-1 scroll-smooth overscroll-x-contain shrink-0"
style="overscroll-behavior-x: contain; touch-action: pan-x"
@wheel="handleWheel"
@scroll="handleScroll"
>
<button
v-for="date in availableDates"
:key="date.value"
:data-date="date.value"
:class="[
'flex flex-col items-center justify-center min-w-[64px] h-16 rounded-lg transition-all duration-200 border shrink-0 relative',
modelValue.includes(date.value)
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
: 'bg-zinc-900 border-zinc-800 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300 hover:border-zinc-700',
date.isToday && !modelValue.includes(date.value) ? 'ring-1 ring-emerald-500/50' : ''
]"
@click="toggleDate(date.value, $event)"
>
<span class="text-[10px] font-bold uppercase tracking-wider opacity-80">{{ date.weekday }}</span>
<span class="text-lg font-black leading-none my-0.5">{{ date.day }}</span>
<span class="text-[9px] font-bold opacity-60">{{ date.month }}月</span>
<div
v-if="modelValue.includes(date.value)"
class="absolute top-1 right-1 w-3.5 h-3.5 rounded-full bg-white text-blue-600 flex items-center justify-center"
>
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><polyline points="20,6 9,17 4,12" /></svg>
</div>
</button>
</div>
<button
:disabled="isLastDateVisible"
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors shrink-0"
@click="scrollDates('right')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9,18 15,12 9,6" /></svg>
</button>
<div class="flex items-center border-l border-zinc-800 ml-1 pl-1 shrink-0">
<button class="p-2 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click="scrollToToday">
<CircleDot class="w-5 h-5" />
</button>
<button
class="p-2 text-zinc-500 hover:text-blue-400 transition-colors"
:class="{ 'text-blue-400': showCalendar }"
title="打开月历及批量选择"
@click="toggleCalendar"
>
<CalendarIcon class="w-5 h-5" />
</button>
</div>
</div>
<!-- 扩展面板:快捷批量 + 月历 -->
<Transition name="expand">
<div v-if="showCalendar" class="border-t border-zinc-800/60 mt-1 pt-2 px-2 pb-2 space-y-3">
<!-- 批量操作按钮 -->
<div class="flex flex-wrap items-center gap-2">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">批量选择</span>
<button
v-for="b in batchActions"
:key="b.label"
class="px-2.5 py-1.5 rounded-lg text-[11px] font-bold bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-zinc-200 hover:border-zinc-700 transition-all"
@click="applyBatchAction(b.action)"
>
{{ b.label }}
</button>
<div class="w-px h-4 bg-zinc-800 mx-1" />
<button
class="px-2.5 py-1.5 rounded-lg text-[11px] font-bold bg-red-500/10 border border-red-500/20 text-red-400 hover:text-red-300 hover:bg-red-500/15 transition-all"
@click="clearAll"
>
清空所有
</button>
<div class="ml-auto flex items-center gap-2 text-[11px] font-bold text-zinc-500">
<Info :size="12" />
<span>已选 {{ modelValue.length }} 天</span>
<span class="text-zinc-700">·</span>
<span class="text-zinc-600">按住 Shift 点选可范围选择</span>
</div>
</div>
<!-- 月历网格 -->
<div class="bg-zinc-950/60 border border-zinc-800 rounded-xl p-3">
<div class="flex items-center justify-between mb-3 px-1">
<button
class="p-1.5 text-zinc-500 hover:text-zinc-200 rounded-lg hover:bg-zinc-800 transition-colors"
@click="calendarMonth--"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="15,18 9,12 15,6" /></svg>
</button>
<div class="flex items-center gap-2">
<span class="text-sm font-black text-zinc-200">{{ calendarYear }}年</span>
<select
v-model="calendarMonth"
class="bg-zinc-900 border border-zinc-800 rounded-lg px-2 py-1 text-sm font-black text-zinc-200 focus:outline-none focus:border-blue-500/50"
>
<option v-for="m in 12" :key="m" :value="m - 1">{{ m }}月</option>
</select>
</div>
<div class="flex items-center gap-1">
<button
class="p-1.5 text-zinc-500 hover:text-zinc-200 rounded-lg hover:bg-zinc-800 transition-colors"
@click="goToday"
title="回到本月"
>
<CircleDot class="w-3.5 h-3.5" />
</button>
<button
class="p-1.5 text-zinc-500 hover:text-zinc-200 rounded-lg hover:bg-zinc-800 transition-colors"
@click="calendarMonth++"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9,18 15,12 9,6" /></svg>
</button>
</div>
</div>
<div class="grid grid-cols-7 mb-1">
<div
v-for="w in weekdayNames"
:key="w"
class="text-center text-[10px] font-black text-zinc-600 uppercase tracking-widest py-1.5"
>
{{ w }}
</div>
</div>
<div class="grid grid-cols-7 gap-1">
<button
v-for="(cell, idx) in calendarCells"
:key="idx"
:disabled="!cell.inMonth"
:class="[
'relative aspect-square rounded-lg flex items-center justify-center text-sm font-bold transition-all',
!cell.inMonth ? 'text-zinc-800 cursor-default' : '',
cell.inMonth && !isSelected(cell.dateStr) && !cell.isToday
? 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200' : '',
cell.inMonth && !isSelected(cell.dateStr) && cell.isToday
? 'text-emerald-400 ring-1 ring-emerald-500/40 hover:bg-zinc-800' : '',
isSelected(cell.dateStr)
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20' : '',
cell.isRangeMiddle && !isSelected(cell.dateStr)
? 'bg-blue-500/10 text-blue-300' : ''
]"
@click="cell.inMonth && toggleDate(cell.dateStr, $event)"
>
{{ cell.day }}
<svg
v-if="isSelected(cell.dateStr)"
class="absolute top-0.5 right-0.5 w-2.5 h-2.5 text-white"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { CircleDot, Calendar as CalendarIcon, Info } from '@lucide/vue'
const props = defineProps({
modelValue: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const dateSelector = ref(null)
const isFirstDateVisible = ref(false)
const isLastDateVisible = ref(false)
const dateRange = ref({ start: -3, end: 38 })
const showCalendar = ref(false)
const lastClickedDate = ref('')
const pad = (n) => n.toString().padStart(2, '0')
const formatDateStr = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const addDays = (d, n) => { const x = new Date(d); x.setHours(0, 0, 0, 0); x.setDate(x.getDate() + n); return x }
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
const sortDates = (arr) => [...new Set(arr)].sort()
const today = new Date(); today.setHours(0, 0, 0, 0)
const calendarYear = ref(today.getFullYear())
const calendarMonth = ref(today.getMonth())
const weekdayNames = ['日', '一', '二', '三', '四', '五', '六']
const availableDates = computed(() => {
const dates = []
for (let i = dateRange.value.start; i <= dateRange.value.end; i++) {
const date = addDays(today, i)
dates.push({
value: formatDateStr(date),
day: date.getDate().toString().padStart(2, '0'),
month: (date.getMonth() + 1).toString().padStart(2, '0'),
weekday: weekdayNames[date.getDay()],
isToday: i === 0
})
}
return dates
})
const batchActions = [
{ label: '今天', action: 'today' },
{ label: '明天起7天', action: 'next7' },
{ label: '本周工作日', action: 'thisWeekWork' },
{ label: '本周全周', action: 'thisWeekAll' },
{ label: '下周工作日', action: 'nextWeekWork' },
{ label: '下周全周', action: 'nextWeekAll' },
{ label: '本月工作日', action: 'thisMonthWork' }
]
const calendarCells = computed(() => {
const cells = []
const firstDay = new Date(calendarYear.value, calendarMonth.value, 1)
const startWeekday = firstDay.getDay()
const startDate = addDays(firstDay, -startWeekday)
for (let i = 0; i < 42; i++) {
const d = addDays(startDate, i)
cells.push({
day: d.getDate(),
dateStr: formatDateStr(d),
inMonth: d.getMonth() === calendarMonth.value,
isToday: sameDay(d, today)
})
}
return cells
})
const isSelected = (d) => props.modelValue.includes(d)
const commitChange = (newArr) => {
const sorted = sortDates(newArr)
emit('update:modelValue', sorted)
emit('change', sorted)
}
const toggleDate = (dateValue, event) => {
const newValue = [...props.modelValue]
if (event && event.shiftKey && lastClickedDate.value && !newValue.includes(dateValue)) {
const a = new Date(lastClickedDate.value)
const b = new Date(dateValue)
const [s, e] = a < b ? [a, b] : [b, a]
for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) {
const ds = formatDateStr(d)
if (!newValue.includes(ds)) newValue.push(ds)
}
} else {
const index = newValue.indexOf(dateValue)
if (index > -1) {
newValue.splice(index, 1)
} else {
newValue.push(dateValue)
}
lastClickedDate.value = dateValue
}
commitChange(newValue)
}
const clearAll = () => commitChange([])
const applyBatchAction = (action) => {
const t = new Date(); t.setHours(0, 0, 0, 0)
const toAdd = []
const pushRange = (start, end) => {
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
toAdd.push(formatDateStr(d))
}
}
const getWeekStart = (base) => {
const r = new Date(base); r.setHours(0, 0, 0, 0)
r.setDate(r.getDate() - r.getDay())
return r
}
switch (action) {
case 'today':
toAdd.push(formatDateStr(t))
break
case 'next7':
pushRange(addDays(t, 1), addDays(t, 7))
break
case 'thisWeekWork': {
const wkStart = getWeekStart(t)
for (let i = 1; i <= 5; i++) toAdd.push(formatDateStr(addDays(wkStart, i)))
break
}
case 'thisWeekAll': {
const wkStart = getWeekStart(t)
pushRange(wkStart, addDays(wkStart, 6))
break
}
case 'nextWeekWork': {
const wkStart = addDays(getWeekStart(t), 7)
for (let i = 1; i <= 5; i++) toAdd.push(formatDateStr(addDays(wkStart, i)))
break
}
case 'nextWeekAll': {
const wkStart = addDays(getWeekStart(t), 7)
pushRange(wkStart, addDays(wkStart, 6))
break
}
case 'thisMonthWork': {
const ym = new Date(t.getFullYear(), t.getMonth(), 1)
const end = new Date(t.getFullYear(), t.getMonth() + 1, 0)
for (let d = new Date(ym); d <= end; d.setDate(d.getDate() + 1)) {
if (d.getDay() >= 1 && d.getDay() <= 5) toAdd.push(formatDateStr(d))
}
break
}
}
const merged = [...new Set([...props.modelValue, ...toAdd])]
commitChange(merged)
}
const handleWheel = (event) => {
event.preventDefault()
if (!dateSelector.value) return
const scrollAmount = 160
const currentScroll = dateSelector.value.scrollLeft
dateSelector.value.scrollTo({
left: event.deltaY > 0 ? currentScroll + scrollAmount : currentScroll - scrollAmount,
behavior: 'smooth'
})
}
const scrollDates = (direction) => {
if (!dateSelector.value) return
const scrollAmount = 160
const currentScroll = dateSelector.value.scrollLeft
dateSelector.value.scrollTo({
left: direction === 'right' ? currentScroll + scrollAmount : currentScroll - scrollAmount,
behavior: 'smooth'
})
}
const handleScroll = () => {
if (!dateSelector.value) return
const { scrollLeft, scrollWidth, clientWidth } = dateSelector.value
if (scrollLeft < 100) {
const oldScrollWidth = scrollWidth
dateRange.value.start -= 5
nextTick(() => {
const newScrollWidth = dateSelector.value.scrollWidth
dateSelector.value.scrollLeft += newScrollWidth - oldScrollWidth
})
}
if (scrollWidth - scrollLeft - clientWidth < 100) {
dateRange.value.end += 5
}
}
const scrollToDateElement = (targetDate, behavior = 'smooth') => {
if (!dateSelector.value || !targetDate) return
const el = dateSelector.value.querySelector(`[data-date="${targetDate}"]`)
if (!el) return
const cw = dateSelector.value.clientWidth
const target = el.offsetLeft - (cw - el.offsetWidth) / 2
dateSelector.value.scrollTo({ left: target, behavior })
}
const scrollToToday = () => {
if (dateRange.value.start > 0 || dateRange.value.end < 0) {
dateRange.value.start = -3
dateRange.value.end = 25
}
nextTick(() => scrollToDateElement(formatDateStr(today), 'smooth'))
}
const toggleCalendar = () => {
showCalendar.value = !showCalendar.value
}
const goToday = () => {
calendarYear.value = today.getFullYear()
calendarMonth.value = today.getMonth()
}
watch(() => props.modelValue, (newVal) => {
if (!newVal || !newVal.length) return
nextTick(() => {
// 跳到第一个 >= 今天的日期,否则跳第一个选中项
const todayStr = formatDateStr(today)
const target = newVal.find(d => d >= todayStr) || newVal[0]
// 如果目标超出当前范围,扩展 range
const diff0 = Math.round((new Date(target) - new Date(todayStr)) / 86400000)
if (diff0 < dateRange.value.start || diff0 > dateRange.value.end) {
dateRange.value.start = Math.min(dateRange.value.start, diff0 - 3)
dateRange.value.end = Math.max(dateRange.value.end, diff0 + 5)
}
nextTick(() => scrollToDateElement(target, 'smooth'))
})
})
onMounted(() => {
if (props.modelValue && props.modelValue.length) {
const todayStr = formatDateStr(today)
const target = props.modelValue.find(d => d >= todayStr) || props.modelValue[0]
const diff0 = Math.round((new Date(target) - new Date(todayStr)) / 86400000)
if (diff0 < dateRange.value.start || diff0 > dateRange.value.end) {
dateRange.value.start = Math.min(dateRange.value.start, diff0 - 3)
dateRange.value.end = Math.max(dateRange.value.end, diff0 + 5)
}
nextTick(() => nextTick(() => scrollToDateElement(target, 'smooth')))
} else {
nextTick(() => scrollToToday())
}
})
</script>
<style scoped>
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.expand-enter-active, .expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.expand-enter-from, .expand-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0 !important;
padding-bottom: 0 !important;
margin-top: 0 !important;
}
.expand-enter-to, .expand-leave-from {
max-height: 800px;
}
</style>