fix(admin): 修复日期选择框尺寸过大与自动排入跨日期逻辑

- **日期选择器尺寸优化**:AdminDatePicker 与 AdminMultiDatePicker 采用 w-9 h-9 的紧凑卡片,缩小字号、边距与圆角,贴合后台窄布局。
- **自动排入逻辑重构**:取消严格的 `expectedPlayDate === selectedDate` 过滤,改为按歌曲期望日期/时段自动分配 `playDate` 与 `playTimeId`;其他歌曲排到当前选中日期。
- **跨日期本地暂存池**:引入 `crossDateLocalPool` 保存尚未保存但已排入其他日期的临时排期,切换 `selectedDate` 或时段时正确回填对应日期的草稿,避免自动排入后切换视图导致丢失。
- **排期列表去重与过滤**:时段过滤逻辑修复 `playTimeId == null` 的边界,并在合并公开/草稿/本地暂存时按优先级去重。
This commit is contained in:
2026-09-13 16:18:09 +08:00
parent c4c30de416
commit 37fd18aaea
3 changed files with 158 additions and 66 deletions
+13 -13
View File
@@ -1,28 +1,28 @@
<template>
<div class="relative bg-zinc-900/50 border border-zinc-800/50 rounded-2xl p-1 overflow-hidden">
<div class="relative bg-zinc-900/50 border border-zinc-800/50 rounded-xl p-1 overflow-hidden">
<!-- 简化版日期选择显示 -->
<div class="flex items-center justify-between px-3 py-2" @click="toggleCalendar" style="cursor: pointer">
<div class="flex items-center gap-3">
<div class="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-blue-600 text-white shadow-lg shadow-blue-900/20">
<span class="text-[10px] font-bold uppercase tracking-wider opacity-80">{{ getWeekday(modelValue) }}</span>
<span class="text-lg font-black leading-none my-0.5">{{ getDay(modelValue) }}</span>
<div class="flex items-center justify-between px-2.5 py-1.5" @click="toggleCalendar" style="cursor: pointer">
<div class="flex items-center gap-2.5">
<div class="flex flex-col items-center justify-center w-9 h-9 rounded-md bg-blue-600 text-white shadow shadow-blue-900/20">
<span class="text-[9px] font-bold uppercase tracking-wider opacity-80 leading-tight">{{ getWeekday(modelValue) }}</span>
<span class="text-base font-black leading-none my-0">{{ getDay(modelValue) }}</span>
</div>
<div class="flex flex-col">
<span class="text-sm font-bold text-zinc-200">{{ modelValue }}</span>
<span class="text-xs text-zinc-500 font-medium">{{ isToday(modelValue) ? '今天' : '' }}</span>
<span class="text-xs font-bold text-zinc-200">{{ modelValue }}</span>
<span class="text-[10px] text-zinc-500 font-medium leading-tight">{{ isToday(modelValue) ? '今天' : '' }}</span>
</div>
</div>
<div class="flex items-center border-l border-zinc-800 pl-3">
<button class="p-2 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click.stop="scrollToToday">
<CircleDot class="w-5 h-5" />
<div class="flex items-center border-l border-zinc-800 pl-2">
<button class="p-1.5 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click.stop="scrollToToday">
<CircleDot class="w-4 h-4" />
</button>
<button
class="p-2 text-zinc-500 hover:text-blue-400 transition-colors"
class="p-1.5 text-zinc-500 hover:text-blue-400 transition-colors"
:class="{ 'text-blue-400': showCalendar }"
title="打开月历"
>
<CalendarIcon class="w-5 h-5" />
<CalendarIcon class="w-4 h-4" />
</button>
</div>
</div>
+29 -13
View File
@@ -1,28 +1,34 @@
<template>
<div class="relative bg-zinc-900/50 border border-zinc-800/50 rounded-2xl p-1 overflow-hidden">
<div class="relative bg-zinc-900/50 border border-zinc-800/50 rounded-xl p-1 overflow-hidden">
<!-- 简化版多选日期显示 -->
<div class="flex items-center justify-between px-3 py-2" @click="toggleCalendar" style="cursor: pointer">
<div class="flex items-center gap-3">
<div class="flex flex-col">
<span class="text-sm font-bold text-zinc-200">
已选择 {{ modelValue.length }} 天
<div class="flex items-center justify-between px-2.5 py-1.5" @click="toggleCalendar" style="cursor: pointer">
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<div class="flex flex-col items-center justify-center w-9 h-9 rounded-md bg-blue-600 text-white shadow shadow-blue-900/20 shrink-0">
<span class="text-[9px] font-bold uppercase tracking-wider opacity-80 leading-tight">
{{ modelValue.length }}
</span>
<span class="text-xs text-zinc-500 font-medium truncate max-w-[200px]">
{{ modelValue.join(', ') || '未选择日期' }}
<span class="text-[10px] font-bold leading-none opacity-80">天</span>
</div>
<div class="flex flex-col min-w-0 flex-1">
<span class="text-xs font-bold text-zinc-200 truncate">
{{ modelValue.length ? modelValue.slice(0, 3).join('、') + (modelValue.length > 3 ? ` 等${modelValue.length}天` : '') : '未选择日期' }}
</span>
<span class="text-[10px] text-zinc-500 font-medium leading-tight truncate">
{{ selectedDatesSummary }}
</span>
</div>
</div>
<div class="flex items-center border-l border-zinc-800 pl-3">
<button class="p-2 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click.stop="scrollToToday">
<CircleDot class="w-5 h-5" />
<div class="flex items-center border-l border-zinc-800 pl-2 ml-2 shrink-0">
<button class="p-1.5 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click.stop="scrollToToday">
<CircleDot class="w-4 h-4" />
</button>
<button
class="p-2 text-zinc-500 hover:text-blue-400 transition-colors"
class="p-1.5 text-zinc-500 hover:text-blue-400 transition-colors"
:class="{ 'text-blue-400': showCalendar }"
title="打开月历"
>
<CalendarIcon class="w-5 h-5" />
<CalendarIcon class="w-4 h-4" />
</button>
</div>
</div>
@@ -165,6 +171,16 @@ const calendarMonth = ref(today.getMonth())
const weekdayNames = ['日', '一', '二', '三', '四', '五', '六']
const selectedDatesSummary = computed(() => {
if (!props.modelValue || props.modelValue.length === 0) return '点击右侧按钮选择日期'
const sorted = sortDates(props.modelValue)
const todayStr = formatDateStr(today)
if (sorted.includes(todayStr)) return `含今天 共 ${sorted.length} 天`
const first = sorted[0]
const last = sorted[sorted.length - 1]
return `${first} ~ ${last}`
})
const batchActions = [
{ label: '今天', action: 'today' },
{ label: '明天起7天', action: 'next7' },
+110 -34
View File
@@ -1083,6 +1083,9 @@ const localScheduledSongs = ref([])
const replayRequests = ref([])
const scheduledSongIds = ref(new Set())
// 跨日期本地暂存池:保存未保存但分配到其他日期的排期,避免切换日期时丢失
const crossDateLocalPool = ref([])
// 计算是否有未发布的草稿
const hasUnpublishedDrafts = computed(() => {
return localScheduledSongs.value.some((schedule) => schedule.isDraft)
@@ -1693,9 +1696,23 @@ const onSemesterChange = async () => {
// 更新本地排期数据(包括草稿)
const updateLocalScheduledSongs = () => {
// 第一步:把当前 localScheduledSongs 中的本地暂存(isLocalOnly)回收到跨日期池
for (const s of localScheduledSongs.value) {
if (s.isLocalOnly) {
const idx = crossDateLocalPool.value.findIndex(p => p.id === s.id)
if (idx === -1) crossDateLocalPool.value.push(s)
else crossDateLocalPool.value[idx] = s
}
}
// 清理池子里已经在公开排期或草稿中的(避免重复)
crossDateLocalPool.value = crossDateLocalPool.value.filter(p => {
return ![...publicSchedules.value, ...drafts.value].some(s => s.id === p.id || (s.song?.id && p.song?.id && s.song.id === p.song.id))
})
console.log('更新本地排期数据 - 当前日期:', selectedDate.value)
console.log('公开排期数量:', publicSchedules.value.length)
console.log('草稿数量:', drafts.value.length)
console.log('跨日期本地暂存池数量:', crossDateLocalPool.value.length)
const _fmtLocal = (d) => {
const x = new Date(d)
@@ -1716,27 +1733,58 @@ const updateLocalScheduledSongs = () => {
return draftDateStr === selectedDate.value
})
// 获取本地暂存池中属于当前日期的
const todayLocalOnly = crossDateLocalPool.value.filter((p) => {
return p.playDate === selectedDate.value
})
// 从池中移除今天的,因为接下来要放入 localScheduledSongs
crossDateLocalPool.value = crossDateLocalPool.value.filter(p => p.playDate !== selectedDate.value)
console.log('当天已发布排期:', todaySchedules.length)
console.log('当天草稿排期:', todayDrafts.length)
console.log('当天本地暂存排期:', todayLocalOnly.length)
// 合并已发布和草稿排期
let allSchedules = [...todaySchedules, ...todayDrafts]
// 合并已发布、草稿、本地暂存
let allSchedules = [...todaySchedules, ...todayDrafts, ...todayLocalOnly]
// 如果选择了特定播出时段,进行过滤
if (selectedPlayTime.value) {
allSchedules = allSchedules.filter((s) => s.playTimeId === parseInt(selectedPlayTime.value))
const ptId = parseInt(selectedPlayTime.value)
allSchedules = allSchedules.filter((s) => {
if (!selectedPlayTime.value) return true
if (s.playTimeId === ptId) return true
// 如果未选择时段(为空)则也不显示,除非时段本身就是空
if (s.playTimeId == null && ptId == null) return true
return s.playTimeId === ptId
})
}
// 去重(根据 song.id):保留优先级:已发布 > 草稿 > 本地暂存
const seenIds = new Set()
allSchedules = allSchedules.filter(s => {
const key = s.song?.id || s.songId || s.id
if (!key) return true
if (seenIds.has(key)) return false
seenIds.add(key)
return true
})
// 按 sequence 字段排序
allSchedules.sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
allSchedules.sort((a, b) => {
const sa = a.sequence || 0
const sb = b.sequence || 0
if (sa !== sb) return sa - sb
return (a.id || 0) - (b.id || 0)
})
localScheduledSongs.value = allSchedules.map((s) => ({ ...s }))
console.log('最终显示排期数量:', localScheduledSongs.value.length)
// 更新已排期歌曲ID集合(包括草稿)
// 更新已排期歌曲ID集合(包括草稿、跨日期暂存、公开排期)
const allScheduledSongs = [...publicSchedules.value, ...drafts.value, ...crossDateLocalPool.value, ...localScheduledSongs.value]
scheduledSongIds.value = new Set(
[...publicSchedules.value, ...drafts.value]
allScheduledSongs
.filter((s) => s.song && s.song.id)
.map((s) => s.song.id)
)
@@ -2055,21 +2103,18 @@ const saveSequence = async () => {
// 一键自动排入期望播出的歌曲
const autoScheduleExpected = () => {
let count = 0
const expectedSongs = [...allUnscheduledSongs.value].filter(song => {
// 严格按期望播出日期排期:如果歌曲有期望播出日期,必须匹配当前选择的日期
if (song.expectedPlayDate && song.expectedPlayDate !== selectedDate.value) {
return false
}
return true
})
if (expectedSongs.length === 0) {
// 1. 收集所有待排期歌曲(不做严格的日期过滤,根据期望日期自动分配)
const pool = [...allUnscheduledSongs.value]
if (pool.length === 0) {
showToast('当前没有待排期的歌曲', 'info')
return
}
// 2. 排序(和之前保持一致:按策略)
if (autoScheduleStrategy.value === 'contributor-count') {
expectedSongs.sort((a, b) => {
pool.sort((a, b) => {
const diffA = getSongContributorCount(b) - getSongContributorCount(a)
if (diffA !== 0) return diffA
const seqDiff = (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999)
@@ -2077,45 +2122,62 @@ const autoScheduleExpected = () => {
return (b.voteCount || 0) - (a.voteCount || 0)
})
} else {
expectedSongs.sort((a, b) => (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999))
pool.sort((a, b) => (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999))
}
const baseId = Date.now()
const filteredPtId = selectedPlayTime.value ? parseInt(selectedPlayTime.value) : null
const playTimeSeqCounters = new Map()
// 3. 初始化每个日期 × 时段的序列计数器
// 结构: dateStr -> Map(playTimeIdStr -> seq)
const dateSeqCounters = new Map()
const initDateCounters = (dateStr) => {
if (!dateSeqCounters.has(dateStr)) {
const ptMap = new Map()
for (const s of localScheduledSongs.value) {
const key = String(s.playTimeId || '')
playTimeSeqCounters.set(key, (playTimeSeqCounters.get(key) || 0) + 1)
if (s.playDate !== dateStr) continue
const k = String(s.playTimeId || '')
ptMap.set(k, (ptMap.get(k) || 0) + 1)
}
dateSeqCounters.set(dateStr, ptMap)
}
return dateSeqCounters.get(dateStr)
}
expectedSongs.forEach((song, idx) => {
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song?.id === song.id)
if (existingIndex !== -1) return
// 4. 处理歌曲,根据期望日期分配 playDate
pool.forEach((song, idx) => {
// 4.1 确定播出日期:优先期望日期,否则用当前 selectedDate
let targetPlayDate = (song.expectedPlayDate && String(song.expectedPlayDate).trim()) || selectedDate.value
// 4.2 确定播出时段:优先期望时段,其次是当前选中的时段视图
let targetPlayTimeId = null
if (song.preferredPlayTimeId != null) {
const matched = (playTimes.value || []).find(pt => String(pt.id) === String(song.preferredPlayTimeId))
if (matched) targetPlayTimeId = matched.id
}
// 如果没有明确期望时段,但当前处于特定时段视图下,则排入该时段
if (targetPlayTimeId == null && filteredPtId != null) {
targetPlayTimeId = filteredPtId
}
// 如果处于特定时段视图,但该歌曲的期望时段不是当前时段,则跳过不排入
// 4.3 如果处于特定时段视图,且该歌曲的时段不匹配(且非空),则跳过
if (filteredPtId != null && targetPlayTimeId != null && String(targetPlayTimeId) !== String(filteredPtId)) {
return
}
// 4.4 检查是否已经存在于已排期列表中
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song?.id === song.id)
if (existingIndex !== -1) return
// 4.5 获取该日期下的计数器
const ptSeq = initDateCounters(targetPlayDate)
const ptKey = String(targetPlayTimeId || '')
const nextSeq = (playTimeSeqCounters.get(ptKey) || 0) + 1
playTimeSeqCounters.set(ptKey, nextSeq)
const nextSeq = (ptSeq.get(ptKey) || 0) + 1
ptSeq.set(ptKey, nextSeq)
const newSchedule = {
id: baseId + idx,
song: song,
playDate: selectedDate.value,
playDate: targetPlayDate,
sequence: nextSeq,
playTimeId: targetPlayTimeId,
isNew: true,
@@ -2126,6 +2188,7 @@ const autoScheduleExpected = () => {
count++
})
// 5. 按 playDate + playTime 顺序重排 sequence
const ptOrder = (id) => {
if (id == null || id === '') return 1e9
const pt = (playTimes.value || []).find(p => p.id === id)
@@ -2133,20 +2196,33 @@ const autoScheduleExpected = () => {
const [h, m] = pt.startTime.split(':').map(Number)
return h * 60 + (m || 0)
}
localScheduledSongs.value.sort((a, b) => {
// 5.1 按日期分组后再排序
const dateGroups = new Map()
for (const s of localScheduledSongs.value) {
const key = String(s.playDate || '')
if (!dateGroups.has(key)) dateGroups.set(key, [])
dateGroups.get(key).push(s)
}
const allSorted = []
const sortedDates = [...dateGroups.keys()].sort()
for (const d of sortedDates) {
const group = dateGroups.get(d)
group.sort((a, b) => {
const po = ptOrder(a.playTimeId) - ptOrder(b.playTimeId)
if (po !== 0) return po
return (a.sequence || 0) - (b.sequence || 0)
})
localScheduledSongs.value.forEach((item, idx) => {
item.sequence = idx + 1
})
group.forEach((item, idx) => { item.sequence = idx + 1 })
allSorted.push(...group)
}
localScheduledSongs.value = allSorted
hasChanges.value = true
const strategyLabel =
autoScheduleStrategy.value === 'contributor-count' ? '(按投稿人数优先)' : ''
const byPref = expectedSongs.filter(s => s.preferredPlayTimeId).length
const prefInfo = byPref ? `,其中 ${byPref} 首按期望播出时段排入` : ''
const byPref = pool.filter(s => s.preferredPlayTimeId || s.expectedPlayDate).length
const prefInfo = byPref ? `,其中 ${byPref} 首按期望日期/时段排入` : ''
showToast(`已自动排入 ${count} 首待排歌曲${strategyLabel}${prefInfo},请注意调整顺序后保存`, 'success')
}