Files
BYW-VoiceHub/app/components/Admin/PlaylistCardGenerator.vue
Henry_Du 3eff710d5d feat(微信订阅): 接入歌曲排期模板并加固部署流程
订阅消息(小程序侧唯一能主动触达用户的通道):
- 新增 server/utils/wechatSubscribeFields.ts:按微信字段类型裁剪值(thing≤20、
  phrase 必须 5 个汉字、time/date/character_string 各自上限),空白压成一行,
  北京时间 time 由 playTime 的开始时间生成,解析不出退化为当天 00:00。
- wechatSubscribeService 内置 SONG_SELECTED 模板(站长新给的那个模板 ID),
  并转发上述纯函数;notificationService.createSongSelectedNotification 现在会带上
  playDate/playTime/sequence,三个排期接口(schedule / publish / bulk-publish)补传 sequence。
- 新增 test/wechatSubscribeFields.test.mjs(10 例,node --test 全绿)。
- test-subscribe 管理接口支持 vars 覆盖并返回真实发送值,方便站长核对字段名。

部署流程(Gitea Actions 容器里没有 buildx 插件,旧流程在此硬失败):
- 每个 job 各自安装/探测 docker-buildx(v0.19.3,走 gh-proxy 再退直连),
  不把 `buildx version` 当唯一依据;装不上时降级为普通 `docker build` 继续发布,
  不再因为缺插件整条流水线失败。

注意:SONG_SELECTED 的字段名(thing2/date4/time1/character_string3/phrase5)是按常见
命名推断的,需在公众平台后台核对;不一致时用环境变量覆盖,模板 ID 变更后老用户需重新授权。
2026-10-11 00:04:14 +08:00

332 lines
10 KiB
Vue
Raw Permalink 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-white border border-zinc-200 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-200">
<h3 class="text-sm font-black text-zinc-900 uppercase tracking-widest">生成歌单卡片</h3>
<button
class="text-zinc-500 hover:text-zinc-600 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-white border border-zinc-200 rounded-lg px-3 py-2 text-sm text-zinc-600 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-white rounded-xl overflow-hidden border border-zinc-200 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-200 bg-zinc-50/50 flex gap-3">
<button
class="flex-1 py-2.5 bg-zinc-100 hover:bg-zinc-200 text-zinc-600 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(0, 0, 0, 0.75)'
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(0, 0, 0, 0.55)'
} else {
ctx.fillStyle = '#CBD5E1'
}
ctx.font = '12px "Inter", "system-ui", sans-serif'
ctx.textAlign = 'center'
ctx.fillText('Generated by 宝一外校园点歌系统', 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>