Files
BYW-VoiceHub/app/components/Admin/PlaylistCardGenerator.vue
T
Henry_Du a55fa96952
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 4m19s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 4m30s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Canceled after 0s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Canceled after 0s
fix(build): SSR 安全化 PlaylistCardGenerator canvasHeight 避免 Nitro 构建 OOM(exit 137)
上版在 canvasHeight computed 里对每首歌 document.createElement('canvas')+getContext('2d'),Nitro prerender/rollup bundler 在 Node 环境下要么 document undefined 抛错 fallback,要么 prerender 时对大歌单反复创建 2D context 撑爆虚拟内存 -> 5min 后 OOM Killer(exit 137)。

修复:
- 新增 isBrowser = typeof document !== 'undefined' 判断
- getSharedCtx(): 浏览器环境才创建 1 个共享 2D context 并跨次复用(不再每首歌 createElement)
- fitText(ctx, ...) 抽成双分支:有 ctx 用 measureText(客户端精确),无 ctx 用 CJK>0x2e80 Unicode 判断+经验字符宽度(16px 中文 1.05em/英文 0.55em)估算换行,保守估算高度
- 同时 fitText 支持文本中带 \\n 换行手动切分
- canvasHeight 中不再 new canvas/context,SSR 环境也能安全执行+算出估算高度
2026-09-13 12:32:51 +08:00

332 lines
10 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>
<Transition name="fade">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click="$emit('close')"
>
<Transition name="scale">
<div
v-if="show"
class="bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden flex flex-col"
@click.stop
>
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">生成歌单卡片</h3>
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors"
@click="$emit('close')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="p-6 flex flex-col items-center gap-4">
<!-- 标题输入 -->
<div class="w-full space-y-2">
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">歌单标题</label>
<input
v-model="cardTitle"
type="text"
placeholder="例如: 2026-03-21 校园午间广播"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:border-blue-500/50 transition-colors"
@input="drawCanvas"
>
</div>
<!-- Canvas 预览 -->
<div class="relative w-full aspect-[2/3] max-w-[280px] bg-zinc-950 rounded-xl overflow-hidden border border-zinc-800 shadow-inner flex items-center justify-center">
<canvas ref="canvasRef" width="400" :height="canvasHeight" class="w-full h-full object-contain bg-white"></canvas>
</div>
<p class="text-[10px] text-zinc-500 text-center">宽度 400 像素,高度自适应,适合在手机端查看和分享</p>
</div>
<div class="p-4 border-t border-zinc-800 bg-zinc-900/50 flex gap-3">
<button
class="flex-1 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
@click="$emit('close')"
>
取消
</button>
<button
class="flex-1 py-2.5 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"
@click="downloadCard"
>
下载图片
</button>
</div>
</div>
</Transition>
</div>
</Transition>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, computed } from 'vue'
const props = defineProps({
show: Boolean,
songs: {
type: Array,
default: () => []
},
defaultTitle: {
type: String,
default: '校园广播歌单'
}
})
const emit = defineEmits(['close'])
const canvasRef = ref(null)
const cardTitle = ref(props.defaultTitle)
const aiPrompt = ref('anime style school campus sunset beautiful sky highly detailed')
const isGeneratingAi = ref(false)
const bgImageObj = ref(null)
const maxDisplaySongs = 200
const songLeftX = 44
const songRightX = 360
const songAreaWidth = songRightX - songLeftX
const isBrowser = typeof document !== 'undefined'
let _sharedCtx = null
const getSharedCtx = () => {
if (!isBrowser) return null
if (_sharedCtx) return _sharedCtx
try {
const c = document.createElement('canvas')
_sharedCtx = c.getContext('2d')
} catch { _sharedCtx = null }
return _sharedCtx
}
const fitText = (ctx, text, fontSize, maxWidth, lineHeight) => {
const hasCtx = !!ctx
const fontFamily = `"Inter", "system-ui", "PingFang SC", "Microsoft YaHei", sans-serif`
const avgCharWidth = fontSize * 0.55
const cjkCharWidth = fontSize * 1.05
const splitLines = String(text || '').split(/\r?\n/)
const lines = []
for (const rawLine of splitLines) {
if (hasCtx) {
ctx.font = `${fontSize}px ${fontFamily}`
const chars = Array.from(rawLine)
let current = ''
for (const ch of chars) {
const tryStr = current + ch
const w = ctx.measureText(tryStr).width
if (w > maxWidth && current) {
lines.push(current)
current = ch
} else {
current = tryStr
}
}
if (current) lines.push(current)
} else {
const chars = Array.from(rawLine)
let current = ''
let curW = 0
for (const ch of chars) {
const code = ch.codePointAt(0) || 0
const isCJK = code > 0x2e80
const w = isCJK ? cjkCharWidth : avgCharWidth
if (curW + w > maxWidth && current) {
lines.push(current)
current = ch
curW = w
} else {
current += ch
curW += w
}
}
if (current) lines.push(current)
}
}
return { lines, lineHeight }
}
const canvasHeight = computed(() => {
const count = Math.min(props.songs.length, maxDisplaySongs)
if (!count) return 600
const ctx = getSharedCtx()
let totalH = 0
const maxWidthTitle = songAreaWidth
const maxWidthArtist = Math.max(100, songAreaWidth - 10)
for (let i = 0; i < count; i++) {
const s = props.songs[i]
const song = s?.song || s || {}
const title = song.title || '未知歌曲'
const artist = song.artist || '未知歌手'
const t1 = fitText(ctx, title, 16, maxWidthTitle, 22)
const t2 = fitText(ctx, `- ${artist}`, 13, maxWidthArtist, 18)
totalH += Math.max(28, (t1.lines.length * t1.lineHeight) + (t2.lines.length * t2.lineHeight) + 10)
}
return 240 + totalH
})
const drawCanvas = () => {
if (!canvasRef.value) return
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return
const height = canvasHeight.value
// 清空画布
if (bgImageObj.value) {
ctx.drawImage(bgImageObj.value, 0, 0, 400, height)
// 增加一层半透明黑色遮罩,确保文字和列表框可见
ctx.fillStyle = 'rgba(0, 0, 0, 0.15)'
ctx.fillRect(0, 0, 400, height)
} else {
ctx.fillStyle = '#F8FAFC' // Background color
ctx.fillRect(0, 0, 400, height)
}
// 绘制渐变头部
const gradient = ctx.createLinearGradient(0, 0, 400, 0)
if (bgImageObj.value) {
gradient.addColorStop(0, 'rgba(0, 0, 0, 0.7)')
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.4)')
} else {
gradient.addColorStop(0, '#2563eb') // Primary Blue
gradient.addColorStop(1, '#3b82f6')
}
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 400, 100)
// 绘制标题
ctx.fillStyle = '#FFFFFF'
ctx.font = 'bold 24px "Inter", "system-ui", sans-serif'
ctx.textAlign = 'center'
ctx.fillText(cardTitle.value || '校园广播歌单', 200, 50)
// 绘制副标题(日期)
ctx.font = '14px "Inter", "system-ui", sans-serif'
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.fillText(new Date().toLocaleDateString('zh-CN'), 200, 80)
// 绘制列表背景
if (bgImageObj.value) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)' // 半透明白色背景
} else {
ctx.fillStyle = '#FFFFFF'
}
ctx.shadowColor = 'rgba(0,0,0,0.05)'
ctx.shadowBlur = 10
ctx.shadowOffsetY = 4
ctx.beginPath()
ctx.roundRect(20, 120, 360, height - 180, 12)
ctx.fill()
ctx.shadowColor = 'transparent'
// 绘制歌曲列表
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
let y = 160
const displaySongs = props.songs.slice(0, maxDisplaySongs)
displaySongs.forEach((schedule, index) => {
const song = schedule.song || schedule
// 序号
ctx.fillStyle = '#2563eb'
ctx.font = 'bold 16px "Inter", "system-ui", "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.textBaseline = 'top'
ctx.fillText(`${index + 1}.`, 40, y)
const maxWidthTitle = songAreaWidth
const maxWidthArtist = Math.max(120, songAreaWidth - 10)
const title = song.title || '未知歌曲'
const artist = song.artist || '未知歌手'
const t1 = fitText(ctx, title, 16, maxWidthTitle, 22)
ctx.fillStyle = '#0F172A'
ctx.font = `bold 16px "Inter", "system-ui", "PingFang SC", "Microsoft YaHei", sans-serif`
let ty = y
for (const line of t1.lines) {
ctx.fillText(line, songLeftX, ty)
ty += t1.lineHeight
}
const t2 = fitText(ctx, `- ${artist}`, 13, maxWidthArtist, 18)
ctx.fillStyle = '#64748B'
ctx.font = `13px "Inter", "system-ui", "PingFang SC", "Microsoft YaHei", sans-serif`
let ay = ty + 4
for (const line of t2.lines) {
ctx.fillText(line, songLeftX, ay)
ay += t2.lineHeight
}
const songBlockHeight = Math.max(28, (ty - y) + (ay - ty) + 4)
y += songBlockHeight + 8
})
if (props.songs.length > maxDisplaySongs) {
ctx.fillStyle = '#94A3B8'
ctx.font = '14px "Inter", "system-ui", sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`...及其他 ${props.songs.length - maxDisplaySongs} 首歌曲`, 200, y + 10)
}
// 绘制底部水印
if (bgImageObj.value) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'
} else {
ctx.fillStyle = '#CBD5E1'
}
ctx.font = '12px "Inter", "system-ui", sans-serif'
ctx.textAlign = 'center'
ctx.fillText('Generated by VoiceHub', 200, height - 20)
}
const downloadCard = () => {
if (!canvasRef.value) return
const dataUrl = canvasRef.value.toDataURL('image/png')
const link = document.createElement('a')
link.download = `歌单卡片_${new Date().toISOString().split('T')[0]}.png`
link.href = dataUrl
link.click()
if (window.$showNotification) {
window.$showNotification('图片导出成功', 'success')
}
}
watch(() => props.show, (newVal) => {
if (newVal) {
cardTitle.value = props.defaultTitle
nextTick(() => {
drawCanvas()
})
}
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.scale-enter-active,
.scale-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.scale-enter-from,
.scale-leave-to {
transform: scale(0.95);
opacity: 0;
}
</style>