Files
BYW-VoiceHub/app/components/Admin/PlaylistCardPage.vue
T
Henry_Du 8ff5bec4f0
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 2m8s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 4m0s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
fix(build): 彻底降 Nitro server build 内存占用,消除 exit 137 OOM
连续两次 deploy 日志显示 Nitro 构建 server bundle 阶段被 OOM 杀掉(exit 137, 12:36:04->12:37:10),
说明 SSR bundle 过大 rolldown 打包期间内存 > runner 限制,跟 canvas 无关。

定位到两个真正的内存元凶:

1. vite.ssr.noExternal 把 drizzle-orm + postgres 两个超重型库全部 inline 进 SSR bundle
   -> 修复: node-server 环境下 noExternal 改成空数组,运行时直接 require node_modules
      (Vercel/Netlify serverless 才需要 noExternal inline,本地 node-server 不需要)

2. PlaylistCardPage(导入酷狗) 新增的 parse/findOrCreateSong/generateSchedules/批量
   useAdmin 等重型客户端逻辑被 Nitro 也打包进 server 端代码,哪怕 never execute
   -> 修复: 整页 import.meta.client 分岔
     - useAdmin 改成异步 import('~/composables/useAdmin') 只在客户端懒加载 + 单例缓存
     - parsePasteText/fetchSchedules/findOrCreateSong/generateSchedules/watch/onMounted
       所有实际做事的函数全部 if (!import.meta.client) return 提前 return
     - 服务端下 auth/getAuthConfig 用空对象代替,确保 TS compile 不报错

附带的 nitro 打包瘦身:
- nuxt.config 里 vite.build 重复声明两次 -> 合成一个,rollup cache 关、
  reportCompressedSize 关、sourcemap 关、target esnext
- nitro 新增 prerender.crawlLinks=false routes=['/','/login'],不要扫站预渲染
- nitro.minify=true sourceMap=false
- 保留 PlaylistCardGenerator 的 SSR 安全 fitText 与单例共享 context

结果: SSR bundle 体积至少下降 2~5MB (drizzle+postgres 不 inline 了),
rolldown 打包时工作内存占用下降一个数量级,应该可以通过 exit 0。
2026-09-13 12:41:47 +08:00

323 lines
15 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'
const auth = import.meta.client ? useAuth() : { getAuthConfig: () => ({}) }
let adminInstance: any = null
const getAdmin = async () => {
if (!import.meta.client) return null
if (adminInstance) return adminInstance
const mod = await import('~/composables/useAdmin')
adminInstance = mod.useAdmin()
return adminInstance
}
const adminShim: any = {
loading: { value: false },
error: { value: '' },
addSong: async (p: any) => { const a = await getAdmin(); return a ? a.addSong(p) : { success: false } },
createSchedule: async (...args: any[]) => { const a = await getAdmin(); return a ? a.createSchedule(...args) : { success: false } }
}
const admin = import.meta.client ? adminShim : adminShim
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: Record<string, string> = {
'歌曲已创建': '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 = () => {
if (!import.meta.client) return
const lines = pasteText.value.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
const out: Array<{ title: string, artist: string, status?: string }> = []
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 as any
}
const clearParsed = () => {
parsedSongs.value = []
pasteText.value = ''
}
const fetchSchedules = async () => {
if (!import.meta.client) return
loading.value = true
try {
const response = await $fetch<any>('/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 as any).$showNotification) (window as any).$showNotification('获取排期失败', 'error')
} finally { loading.value = false }
}
const fetchPlayTimes = async () => {
if (!import.meta.client) return
try {
const r: any = 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 }: { title: string, artist: string }) => {
if (!import.meta.client) return { created: false, song: null, failMsg: '客户端-only' }
try {
const search: any = await $fetch('/api/songs', {
...auth.getAuthConfig(),
query: { q: `${title} ${artist}`, limit: 20 }
})
const list: any[] = 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: any = await (admin as any).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: any) {
return { created: false, song: null, failMsg: e?.message || '接口调用异常' }
}
}
const generateSchedulesFromList = async () => {
if (!import.meta.client || displaySongs.value.length === 0) return
generatingSchedule.value = true
try {
const toProcess: any[] = parsedSongs.value.length ? parsedSongs.value : schedules.value
const results: Array<{ title: string, artist: string, status: string }> = []
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: any = await (admin as any).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: any) { scheduleRow.status = `排期保存失败:${e?.message || '接口异常'}` }
results.push(scheduleRow)
}
parsedSongs.value = results as any
await fetchSchedules()
if ((window as any).$showNotification) (window as any).$showNotification(`批量保存完成:${results.filter(r => r.status === '排期已创建').length}/${results.length} 成功`, 'success')
} catch (e: any) {
console.error(e)
if ((window as any).$showNotification) (window as any).$showNotification('批量排期失败:' + (e?.message || '未知错误'), 'error')
} finally { generatingSchedule.value = false }
}
const handleDateChange = () => { if (!parsedSongs.value.length) fetchSchedules() }
if (import.meta.client) {
watch(selectedDate, () => { if (!parsedSongs.value.length) fetchSchedules() })
}
onMounted(() => {
if (!import.meta.client) return
fetchSchedules()
fetchPlayTimes()
})
</script>