Files
BYW-VoiceHub/app/components/Admin/PlaylistCardPage.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

302 lines
14 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="flex flex-col space-y-8 pb-12 lg:pb-20 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="flex flex-col space-y-2">
<h2 class="text-2xl font-bold tracking-tight text-zinc-100">导入酷狗</h2>
<p class="text-sm text-zinc-500">粘贴酷狗歌单文本 或 选择日期读取已有排期,一键生成该日期的排期并导出宣传卡片</p>
</div>
<div class="flex flex-col gap-8">
<!-- 歌单导入区 -->
<div class="w-full flex flex-col gap-6">
<div class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 space-y-6 shadow-lg">
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-100 shrink-0">
<Music2 class="w-4 h-4 text-blue-500" /> 歌单导入
</h3>
<div class="space-y-2 w-full">
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">粘贴酷狗歌单文本</label>
<textarea
v-model="pasteText"
rows="7"
class="w-full resize-y rounded-xl border border-zinc-800 bg-zinc-950/60 text-sm text-zinc-200 px-4 py-3 focus:outline-none focus:border-blue-500/70 font-mono"
placeholder="每行一首,支持以下格式之一:&#10;1. 序号 歌曲名 - 歌手&#10;2. 歌曲名 - 歌手&#10;3. 歌曲名 (歌手)&#10;示例:&#10;01 晴天 - 周杰伦&#10;稻香 (周杰伦)&#10;七里香-周杰伦"
/>
<div class="flex flex-wrap gap-2 pt-1">
<button
:disabled="!pasteText.trim()"
class="px-3 py-2 text-xs font-bold rounded-xl bg-zinc-800 text-zinc-200 hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@click="parsePasteText"
>
<ListPlus class="w-3.5 h-3.5 inline-block mr-1" />解析文本
</button>
<button
v-if="parsedSongs.length > 0"
class="px-3 py-2 text-xs font-bold rounded-xl bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors"
@click="clearParsed"
>
<Trash2 class="w-3.5 h-3.5 inline-block mr-1" />清空解析结果
</button>
</div>
</div>
</div>
<!-- 设置面板 -->
<div class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 space-y-6 shadow-lg">
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-100 shrink-0">
<Layout class="w-4 h-4 text-blue-500" /> 生成设置
</h3>
<div class="flex flex-col gap-5">
<!-- 日期选择 -->
<div class="space-y-2 w-full">
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">排期日期</label>
<AdminDatePicker
:modelValue="selectedDate"
@update:modelValue="selectedDate = $event"
@change="handleDateChange"
/>
</div>
<!-- 播出时段 -->
<div class="space-y-2 w-full">
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">默认播出时段(未选时不分配时段)</label>
<select
v-model="selectedPlayTimeId"
class="w-full h-[42px] px-4 rounded-xl border border-zinc-800 bg-zinc-950/60 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/70"
>
<option :value="null">不指定时段</option>
<option v-for="pt in playTimes" :key="pt.id" :value="pt.id">
{{ pt.name }}({{ pt.startTime || '--:--' }} ~ {{ pt.endTime || '--:--' }}){{ pt.enabled ? '' : ' - 已禁用' }}
</option>
</select>
</div>
<div class="flex flex-col sm:flex-row gap-3 w-full pt-1">
<button
:disabled="generatingSchedule || (parsedSongs.length === 0 && schedules.length === 0)"
class="flex-1 py-3 h-[54px] bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-emerald-900/20 transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shrink-0"
@click="generateSchedulesFromList"
>
<Save class="w-4 h-4" />
{{ generatingSchedule ? '保存中...' : (parsedSongs.length ? `将 ${parsedSongs.length} 首生成为排期` : (schedules.length ? `将现有 ${schedules.length} 首重新保存` : '无可用歌曲')) }}
</button>
<button
:disabled="loading || displaySongs.length === 0"
class="flex-1 py-3 h-[54px] bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shrink-0"
@click="showModal = true"
>
<ImageIcon class="w-4 h-4" />
{{ loading ? '加载中...' : (displaySongs.length === 0 ? '无歌曲可预览' : '生成卡片') }}
</button>
</div>
</div>
</div>
</div>
<!-- 预览区 -->
<div class="w-full flex flex-col gap-4">
<div v-if="displaySongs.length > 0" class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 shadow-lg">
<h3 class="text-lg font-bold text-zinc-100 mb-4 flex items-center justify-between">
<span>
{{ parsedSongs.length > 0 ? '解析结果预览' : '排期预览' }}({{ displaySongs.length }} 首)
</span>
<span class="text-xs font-medium text-zinc-500">歌曲名与歌手名将完整显示,不会简写或隐藏</span>
</h3>
<div class="space-y-3">
<div v-for="(item, index) in displaySongs" :key="index" class="flex items-start gap-3 p-3 bg-zinc-950/50 border border-zinc-800/50 rounded-lg">
<span class="text-blue-500 font-bold w-6 pt-0.5">{{ index + 1 }}.</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-zinc-200 font-medium break-words whitespace-normal">{{ item.song?.title || item.title }}</p>
<p class="text-xs text-zinc-500 break-words whitespace-normal mt-1">{{ item.song?.artist || item.artist }}</p>
<p v-if="item.status" class="text-[11px] mt-2 inline-block px-2 py-0.5 rounded-full" :class="statusClass(item.status)">
{{ item.status }}
</p>
</div>
</div>
</div>
</div>
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-20 bg-zinc-900/20 border border-zinc-800/50 rounded-xl border-dashed">
<ListMusic class="w-12 h-12 text-zinc-700 mb-4" />
<p class="text-zinc-500">尚未有歌曲,请粘贴歌单文本解析 或 选择日期读取已有排期</p>
</div>
</div>
</div>
<AdminPlaylistCardGenerator
:show="showModal"
:songs="displaySongs"
:default-title="`${selectedDate} 校园广播歌单`"
@close="showModal = false"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Layout, Image as ImageIcon, Music2, ListPlus, Trash2, Save, ListMusic } from '@lucide/vue'
import AdminDatePicker from './AdminDatePicker.vue'
import AdminPlaylistCardGenerator from './PlaylistCardGenerator.vue'
import { useAuth } from '~/composables/useAuth'
import { useAdmin } from '~/composables/useAdmin'
const auth = useAuth()
const admin = useAdmin()
const today = new Date()
today.setHours(0, 0, 0, 0)
const _fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const selectedDate = ref(_fmt(today))
const schedules = ref([])
const parsedSongs = ref([])
const loading = ref(false)
const showModal = ref(false)
const pasteText = ref('')
const selectedPlayTimeId = ref(null)
const generatingSchedule = ref(false)
const playTimes = ref([])
const displaySongs = computed(() => parsedSongs.value.length ? parsedSongs.value : schedules.value)
const statusClass = (s) => {
const map = {
'歌曲已创建': 'bg-emerald-500/15 text-emerald-400',
'已匹配现有歌曲': 'bg-blue-500/15 text-blue-400',
'匹配失败(未创建)': 'bg-amber-500/15 text-amber-400',
'排期保存失败': 'bg-red-500/15 text-red-400',
'排期已创建': 'bg-emerald-500/15 text-emerald-400'
}
return map[s] || 'bg-zinc-800 text-zinc-400'
}
const parsePasteText = () => {
const lines = pasteText.value.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
const out = []
for (const line of lines) {
let t = line
t = t.replace(/^[\d]+[\s.、\-、)]+/, '')
t = t.replace(/^\([\d]+\)\s*/, '')
t = t.replace(/^【.*?】\s*/, '')
t = t.trim()
if (!t) continue
let title = ''
let artist = ''
const m1 = t.match(/^(.+?)[\s]*[-–—][\s]*(.+)$/)
const m2 = t.match(/^(.+?)[\s]*\(([^)]+)\)\s*$/)
const m3 = t.match(/^(.+?)[\s]*(([^)]+))\s*$/)
if (m1) { title = m1[1].trim(); artist = m1[2].trim() }
else if (m2) { title = m2[1].trim(); artist = m2[2].trim() }
else if (m3) { title = m3[1].trim(); artist = m3[2].trim() }
else { title = t; artist = '' }
if (title) out.push({ title, artist })
}
parsedSongs.value = out
}
const clearParsed = () => {
parsedSongs.value = []
pasteText.value = ''
}
const fetchSchedules = async () => {
loading.value = true
try {
const response = await $fetch('/api/admin/schedule/full', {
...auth.getAuthConfig(),
query: { date: selectedDate.value, includeDrafts: 'true' }
})
if (response.success && response.data && response.data.schedules) {
schedules.value = response.data.schedules
} else schedules.value = []
} catch (error) {
console.error('Failed to fetch schedules:', error)
if (window.$showNotification) window.$showNotification('获取排期失败', 'error')
} finally { loading.value = false }
}
const fetchPlayTimes = async () => {
try {
const r = await $fetch('/api/admin/play-times', { ...auth.getAuthConfig() })
if (r && r.success && r.data) playTimes.value = (r.data.playTimes || r.data || []).filter(Boolean)
} catch (e) { console.warn(e) }
}
const findOrCreateSong = async ({ title, artist }) => {
try {
const search = await $fetch('/api/songs', {
...auth.getAuthConfig(),
query: { q: `${title} ${artist}`, limit: 20 }
})
const list = search?.data?.songs || search?.songs || []
const hit = list.find(s =>
s.title && s.title.toLowerCase() === title.toLowerCase() &&
(!artist || !s.artist || s.artist.toLowerCase() === artist.toLowerCase())
)
if (hit) return { created: false, song: hit }
const addResult = await admin.addSong({
title, artist: artist || '未知歌手', language: 'Chinese', sourceType: 'kugou', status: 'approved',
contributor: 'admin', musicPlatform: 'kugou', platformSongId: '',
lyrics: '', coverUrl: '', duration: 0, sourceUrl: ''
})
if (addResult?.success && addResult.data) return { created: true, song: addResult.data }
if (addResult?.success && addResult?.data?.song) return { created: true, song: addResult.data.song }
if (addResult?.song) return { created: true, song: addResult.song }
return { created: false, song: null, failMsg: addResult?.message || '返回值无法解析' }
} catch (e) {
return { created: false, song: null, failMsg: e?.message || '接口调用异常' }
}
}
const generateSchedulesFromList = async () => {
if (displaySongs.value.length === 0) return
generatingSchedule.value = true
try {
const toProcess = parsedSongs.value.length ? parsedSongs.value : schedules.value
const results = []
const playDateStr = selectedDate.value
let nextSeq = 1
for (const row of toProcess) {
const title = row.song?.title || row.title || ''
const artist = row.song?.artist || row.artist || ''
const scheduleRow = { title, artist, status: '' }
if (!title) { scheduleRow.status = '歌曲为空已跳过'; results.push(scheduleRow); continue }
let songObj = row.song || null
if (!songObj) {
const found = await findOrCreateSong({ title, artist })
if (found.song) {
songObj = found.song
scheduleRow.status = found.created ? '歌曲已创建' : '已匹配现有歌曲'
} else {
scheduleRow.status = `匹配失败(未创建):${found.failMsg || '未知原因'}`
results.push(scheduleRow)
continue
}
} else {
scheduleRow.status = '已匹配现有歌曲'
}
try {
const res = await admin.createSchedule(Number(songObj.id), playDateStr,
(selectedPlayTimeId.value != null) ? Number(selectedPlayTimeId.value) : (songObj.preferredPlayTimeId || null), nextSeq)
if (res?.success) { scheduleRow.status = '排期已创建'; nextSeq++ }
else { scheduleRow.status = `排期保存失败:${res?.message || '未知原因'}` }
} catch (e) { scheduleRow.status = `排期保存失败:${e?.message || '接口异常'}` }
results.push(scheduleRow)
}
parsedSongs.value = results
await fetchSchedules()
if (window.$showNotification) window.$showNotification(`批量保存完成:${results.filter(r => r.status === '排期已创建').length}/${results.length} 成功`, 'success')
} catch (e) {
console.error(e)
if (window.$showNotification) window.$showNotification('批量排期失败:' + (e?.message || '未知错误'), 'error')
} finally { generatingSchedule.value = false }
}
const handleDateChange = () => { if (!parsedSongs.value.length) fetchSchedules() }
watch(selectedDate, () => { if (!parsedSongs.value.length) fetchSchedules() })
onMounted(() => {
fetchSchedules()
fetchPlayTimes()
})
</script>