RecallPoster.vue 11.7 KB
<template>
  <div
    class="recall-poster-container relative mx-auto my-auto w-full max-w-[340px] shrink-0 select-none"
  >
    <!-- 最终生成的海报图片展示区域 -->
    <div v-if="posterImgSrc" class="fade-in relative w-full">
      <img :src="posterImgSrc" class="block h-auto w-full rounded-2xl shadow-2xl" alt="分享海报" />
      <div class="mt-4 text-center text-xs text-white/80">长按图片保存</div>
    </div>

    <!-- 生成中/加载中占位 -->
    <div
      v-else
      class="flex h-[62vh] min-h-[400px] w-full flex-col items-center justify-center rounded-2xl bg-white/10 text-white/80 backdrop-blur-sm"
    >
      <van-loading type="spinner" color="#ffffff" size="32px" />
      <div class="mt-4 text-sm font-medium">海报生成中...</div>
    </div>

    <!-- Canvas (隐藏) -->
    <canvas ref="canvasRef" class="hidden"></canvas>
  </div>
</template>

<script setup>
import { ref, watch, nextTick, onMounted } from 'vue'
import { showToast } from 'vant'
// import request from '@/utils/axios' // 移除 axios 依赖,改用 fetch

const props = defineProps({
  /** 海报背景图片 URL */
  bgUrl: {
    type: String,
    required: true,
  },
  /** 海报标题 */
  title: {
    type: String,
    default: '',
  },
  /** Logo 图片 URL */
  logoUrl: {
    type: String,
    default: 'https://cdn.ipadbiz.cn/mlaj/recall/poster/kai@2x.png',
  },
  /** 二维码图片 URL */
  qrUrl: {
    type: String,
    default: 'https://cdn.ipadbiz.cn/mlaj/recall/poster/%E4%BA%8C%E7%BB%B4%E7%A0%81@2x.png',
  },
})

const canvasRef = ref(null)
const posterImgSrc = ref('')

// 工具函数:加载图片
const loadImage = src =>
  new Promise((resolve, reject) => {
    if (!src) {
      reject(new Error('Image source is empty'))
      return
    }
    const img = new Image()
    // 处理跨域,Blob URL 不需要
    if (!src.startsWith('blob:') && !src.startsWith('data:')) {
      img.crossOrigin = 'anonymous'
    }
    img.onload = () => resolve(img)
    img.onerror = e => {
      console.error('Failed to load image:', src)
      // 图片加载失败不应该阻断流程,返回 null 或者透明图占位
      // 这里resolve null,绘制时跳过
      resolve(null)
    }
    img.src = src
  })

// 工具函数:绘制圆角矩形
const drawRoundedRect = (ctx, x, y, width, height, radius) => {
  ctx.beginPath()
  ctx.moveTo(x + radius, y)
  ctx.lineTo(x + width - radius, y)
  ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
  ctx.lineTo(x + width, y + height - radius)
  ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
  ctx.lineTo(x + radius, y + height)
  ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
  ctx.lineTo(x, y + radius)
  ctx.quadraticCurveTo(x, y, x + radius, y)
  ctx.closePath()
}

// 工具函数:绘制多行文本
/**
 * 绘制多行文本
 * @param {CanvasRenderingContext2D} ctx - Canvas上下文
 * @param {string} text - 文本内容
 * @param {number} x - x坐标
 * @param {number} y - y坐标
 * @param {number} maxWidth - 最大宽度
 * @param {number} lineHeight - 行高
 * @param {number} maxLines - 最大行数
 */
const wrapText = (ctx, text, x, y, maxWidth, lineHeight, maxLines) => {
  const words = text.split('') // 中文按字分割
  let line = ''
  let lineCount = 0
  let currentY = y

  for (let n = 0; n < words.length; n++) {
    const testLine = line + words[n]
    const metrics = ctx.measureText(testLine)
    const testWidth = metrics.width

    if (testWidth > maxWidth && n > 0) {
      ctx.fillText(line, x, currentY)
      line = words[n]
      currentY += lineHeight
      lineCount++
      if (maxLines && lineCount >= maxLines) {
        // 超过最大行数,最后一行加省略号(简化处理,暂不精确计算省略号位置)
        return
      }
    } else {
      line = testLine
    }
  }
  ctx.fillText(line, x, currentY)
}

// 工具函数:绘制竖排文字
const drawVerticalText = (ctx, text, x, y, fontSize, letterSpacing) => {
  const chars = text.split('')
  let currentY = y
  ctx.font = `500 ${fontSize}px sans-serif`
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'

  chars.forEach(char => {
    ctx.fillText(char, x, currentY)
    currentY += fontSize + letterSpacing
  })
}

// 核心生成逻辑:Canvas 绘制
const generatePoster = async () => {
  posterImgSrc.value = ''

  // 确保 Canvas 元素存在
  await nextTick()
  const canvas = canvasRef.value
  if (!canvas) return

  try {
    // 1. 准备画布尺寸 (2倍图)
    const scale = 2
    const width = 375 * scale
    // 图片区域高度缩小,从 600 -> 450,保持较好比例
    const imgAreaHeight = 450 * scale
    const infoAreaHeight = 130 * scale
    const height = imgAreaHeight + infoAreaHeight

    canvas.width = width
    canvas.height = height
    const ctx = canvas.getContext('2d')

    // 设置白色背景
    ctx.fillStyle = '#ffffff'
    ctx.fillRect(0, 0, width, height)

    // 处理 API 形式的二维码 URL (如 /admin/?m=srv...)
    // 如果是相对路径或特定API,使用 fetch 获取 blob 以通过鉴权并避免跨域/拦截器问题
    let qrUrlToLoad = props.qrUrl
    let qrBlobUrl = null
    // 兼容完整 URL 和相对路径,只要包含特定特征或是相对路径
    if (
      qrUrlToLoad &&
      (qrUrlToLoad.startsWith('/') || qrUrlToLoad.includes('m=srv') || qrUrlToLoad.includes('http'))
    ) {
      try {
        // 使用 fetch 替代 axios,避免拦截器自动添加 header 导致 CORS 预检失败
        const res = await fetch(qrUrlToLoad)
        if (res.ok) {
          const blob = await res.blob()
          qrBlobUrl = URL.createObjectURL(blob)
          qrUrlToLoad = qrBlobUrl
        }
      } catch (err) {
        console.warn('QR Code API fetch failed, falling back to direct load', err)
      }
    }

    // 2. 并行加载所有图片资源
    const [bgImg, logoImg, qrImg] = await Promise.all([
      loadImage(props.bgUrl),
      loadImage(props.logoUrl),
      loadImage(qrUrlToLoad),
    ])

    // 清理 Blob URL
    if (qrBlobUrl) {
      URL.revokeObjectURL(qrBlobUrl)
    }

    // 3. 绘制背景图 (Object-Cover 效果)
    if (bgImg) {
      // 计算裁剪
      const imgRatio = bgImg.width / bgImg.height
      const canvasRatio = width / imgAreaHeight
      let sx, sy, sWidth, sHeight

      if (imgRatio > canvasRatio) {
        // 图片更宽,裁左右
        sHeight = bgImg.height
        sWidth = sHeight * canvasRatio
        sx = (bgImg.width - sWidth) / 2
        sy = 0
      } else {
        // 图片更高,裁上下
        sWidth = bgImg.width
        sHeight = sWidth / canvasRatio
        sx = 0
        sy = (bgImg.height - sHeight) / 2
      }

      ctx.drawImage(bgImg, sx, sy, sWidth, sHeight, 0, 0, width, imgAreaHeight)
    }

    // 4. 绘制 Logo (左上角)
    if (logoImg) {
      const logoW = 96 * scale // w-24 = 96px
      const logoRatio = logoImg.width / logoImg.height
      const logoH = logoW / logoRatio
      const logoX = 24 * scale // left-6
      const logoY = 24 * scale // top-6

      // 添加阴影
      ctx.shadowColor = 'rgba(0, 0, 0, 0.2)'
      ctx.shadowBlur = 4 * scale
      ctx.shadowOffsetY = 2 * scale
      ctx.drawImage(logoImg, logoX, logoY, logoW, logoH)
      ctx.shadowColor = 'transparent' // 重置阴影
    }

    // 5. 绘制竖排文字 (右下角)
    // 位置: right-5 (20px), bottom-6 (24px) relative to imgArea
    const textRightMargin = 20 * scale
    const textBottomMargin = 24 * scale
    const fontSize = 13 * scale
    const letterSpacing = fontSize * 0.5 // tracking-[0.5em]

    ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'
    // Shadow
    ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
    ctx.shadowBlur = 2 * scale
    ctx.shadowOffsetY = 1 * scale

    // Column 1: "每一段成长故事" (左边那列,离右边远一点)
    // Column 2: "见证我在生命力教育联盟宇宙的" (右边那列)

    const colRightX = width - textRightMargin - fontSize / 2
    const colLeftX = colRightX - fontSize - 12 * scale // gap-3 = 12px

    const textRight = '见证我在生命力教育联盟宇宙的'
    const textLeft = '每一段成长故事'

    // 计算文本对齐
    // 目标:整个文本块的底部与 bottom-6 对齐
    // 右列(第一句)作为基准
    // 左列(第二句)相对于右列下移 48px (mt-12)

    const charH = fontSize + letterSpacing
    const hRight = textRight.length * charH - letterSpacing
    const hLeft = textLeft.length * charH - letterSpacing
    const offset = 48 * scale // mt-12

    const maxBottom = imgAreaHeight - textBottomMargin
    // 计算起始 Y 坐标,使得最底部的点不超过 maxBottom
    const heightDiff = Math.max(hRight, offset + hLeft)
    const tRight = maxBottom - heightDiff
    const tLeft = tRight + offset

    // 设置字体
    ctx.font = `500 ${fontSize}px sans-serif`

    // 内部绘制函数
    const drawVert = (txt, x, startY) => {
      const chars = txt.split('')
      let cy = startY + fontSize / 2
      ctx.textAlign = 'center'
      ctx.textBaseline = 'middle'
      chars.forEach(char => {
        ctx.fillText(char, x, cy)
        cy += fontSize + letterSpacing
      })
    }

    drawVert(textRight, colRightX, tRight)
    drawVert(textLeft, colLeftX, tLeft)

    ctx.shadowColor = 'transparent'

    // 6. 绘制 Info Area 内容
    // 坐标参考
    const infoY = imgAreaHeight

    // 6.1 Title
    // Padding: p-5 (20px). Left side.
    // pr-4 (16px) for title container.
    // Title Width = Total Width - Padding Left - Padding Right - QR Section Width
    // QR Section: w-[72px] + margins?
    // DOM: justify-between.
    // QR Section is shrink-0.
    // QR Image: 72px. Text below.
    // Let's reserve 100px width for QR section on the right.

    const padding = 20 * scale
    const titleX = padding
    const titleY = infoY + padding
    const qrSectionW = 85 * scale // approx
    const titleMaxW = width - padding - qrSectionW - 10 * scale // extra gap

    ctx.fillStyle = '#0052D9'
    ctx.font = `bold ${15 * scale}px sans-serif`
    ctx.textAlign = 'left'
    ctx.textBaseline = 'top'

    // Line height: leading-relaxed (approx 1.6?)
    const lineHeight = 15 * scale * 1.6
    wrapText(ctx, props.title, titleX, titleY, titleMaxW, lineHeight, 4)

    // 6.2 QR Code
    if (qrImg) {
      const qrSize = 72 * scale
      // Center in the right section
      // Section starts at width - padding - qrSectionW?
      // Actually DOM is flex justify-between.
      // QR is at the very right (minus padding).
      const qrX = width - padding - qrSize + 4 * scale // slight adjustment
      const qrY = infoY + padding

      ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize)

      // 6.3 QR Text
      // text-[10px], scale-90. Effective size 9px.
      // text-[#666]
      const smallTextSize = 10 * scale * 0.9
      ctx.fillStyle = '#666666'
      ctx.font = `${smallTextSize}px sans-serif`
      ctx.textAlign = 'center'

      const textCenterX = qrX + qrSize / 2
      const textStartY = qrY + qrSize + 8 * scale // mb-2 is for img

      ctx.fillText('跟我一起加入', textCenterX, textStartY)
      ctx.fillText('生命力教育联盟宇宙吧', textCenterX, textStartY + smallTextSize * 1.4)
    }

    // 7. 导出图片
    posterImgSrc.value = canvas.toDataURL('image/png')
  } catch (error) {
    console.error('Canvas poster generation failed:', error)
    showToast('生成失败,请重试')
  }
}

watch(
  () => [props.bgUrl, props.qrUrl],
  () => {
    generatePoster()
  }
)

onMounted(() => {
  // 稍微延时确保字体等资源就绪(虽然canvas不强依赖DOM渲染,但字体加载是全局的)
  setTimeout(generatePoster, 500)
})

defineExpose({
  generatePoster,
})
</script>

<style scoped>
.fade-in {
  animation: fadeIn 0.5s ease-in-out;
}

@keyframes fadeIn {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}
</style>