vue3 snapdom 导出图片和pdf

App.vue

javascript 复制代码
<template>
  <div class="message-list-container" ref="messageListRef">
    <div class="message-list-header">
      <h2>消息记录</h2>
      <button
        class="export-button"
        @click="handleExportToPdf"
        :disabled="isExporting"
      >
        {{ isExporting ? '导出中...' : '导出 PDF' }}
      </button>
    </div>

   <div class="message-list">
  <div v-for="item in 9" :key="item" class="message-item">
    <div class="message-avatar">
      <div class="avatar-circle" :style="{ 
        background: `linear-gradient(135deg, ${getRandomColor()}, ${getRandomColor()})`,
        boxShadow: `0 4px 12px ${getRandomColor(0.2)}`
      }">
        <span class="avatar-icon">{{ getAvatarIcon(item) }}</span>
      </div>
    </div>
    
    <div class="message-content-wrapper">
      <div class="message-header">
        <span class="message-sender">{{ getSenderName(item) }}</span>
        <span class="message-status" :class="getStatusClass(item)">
          {{ getStatusText(item) }}
        </span>
        <span class="message-time">{{ getFormattedTime(item) }}</span>
      </div>
      
      <div class="message-body">
        <p class="message-text">{{ getMessageContent(item) }}</p>
        
        <div v-if="item % 3 === 0" class="message-attachment">
          <div class="attachment-preview">
            <div class="attachment-icon">📎</div>
            <div class="attachment-info">
              <span class="attachment-name">document_{{ item }}.pdf</span>
              <span class="attachment-size">{{ getFileSize(item) }}</span>
            </div>
          </div>
        </div>
        
        <div v-if="item % 4 === 0" class="message-reactions">
          <span class="reaction">👍 {{ Math.floor(Math.random() * 10) + 1 }}</span>
          <span class="reaction">❤️ {{ Math.floor(Math.random() * 5) + 1 }}</span>
          <span class="reaction">😄 {{ Math.floor(Math.random() * 3) + 1 }}</span>
        </div>
      </div>
    </div>
  </div>
</div>

  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { exportMessagesToPdf } from '@/utils/messageExportService'

// 定义 props(如果 messages 是外部传入的)
const props = defineProps<{
  messages: Array<{ id: string }> // 根据实际类型调整
}>()

// 响应式状态
const messageListRef = ref<HTMLElement | null>(null)
const isExporting = ref(false)

// 导出逻辑
const handleExportToPdf = async () => {
  isExporting.value = true

  try {
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
    const filename = `messages-${timestamp}.pdf`

    await exportMessagesToPdf({
      targetSelector: '.message-list',
      filename,
      quality: 0.95
    })
  } catch (error) {
    console.error('导出失败:', error)
    alert('导出失败,请重试')
  } finally {
    isExporting.value = false
  }
}

// 随机数据生成函数
const getRandomColor = (alpha = 1) => {
  const colors = [
    `rgba(59, 130, 246, ${alpha})`,  // blue
    `rgba(16, 185, 129, ${alpha})`,  // green
    `rgba(245, 158, 11, ${alpha})`,  // yellow
    `rgba(239, 68, 68, ${alpha})`,   // red
    `rgba(139, 92, 246, ${alpha})`,  // purple
    `rgba(14, 165, 233, ${alpha})`,  // sky
  ];
  return colors[Math.floor(Math.random() * colors.length)];
};
const getAvatarIcon = (index:number) => {
  const icons = ['👤', '👩', '👨', '🧑', '👧', '👦', '👩‍💻', '👨‍💼'];
  return icons[index % icons.length];
};
const getSenderName = (index:number) => {
  const names = [
    '张三', '李四', '王五', '赵六', '钱七', 
    '孙八', '周九', '吴十', '郑十一', '王十二'
  ];
  return names[index % names.length];
};
const getMessageContent = (index:number) => {
  const messages = [
    '你好!这个项目进展如何?',
    '我已经完成了前端界面的开发',
    '需要你帮忙 review 一下代码',
    '会议安排在明天下午3点',
    '附件是详细的需求文档',
    '这个功能预计本周内完成',
    '用户反馈的问题已经修复',
    '新版本已经发布到测试环境',
    '设计稿已经更新,请查收',
    '下周我们有个重要的演示'
  ];
  return messages[index % messages.length];
};
const getFormattedTime = (index:number) => {
  const now = new Date();
  now.setHours(now.getHours() - index);
  return now.toLocaleTimeString('zh-CN', { 
    hour: '2-digit', 
    minute: '2-digit' 
  });
};
const getStatusClass = (index:number) => {
  const statuses = ['delivered', 'read', 'sent'];
  return statuses[index % statuses.length];
};
const getStatusText = (index:number) => {
  const texts = ['已送达', '已读', '已发送'];
  return texts[index % texts.length];
};
const getFileSize = (index:number) => {
  const sizes = ['1.2MB', '2.5MB', '3.8MB', '4.1MB', '5.6MB'];
  return sizes[index % sizes.length];
};


</script>

<style scoped>
.message-list {
  width: 700px;
  padding: 20px;
  overflow-y: auto;
  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.message-item {
  display: flex;
  margin-bottom: 20px;
  padding: 16px;
  background: white;
  border-radius: 16px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  transition: transform 0.2s, box-shadow 0.2s;
}
.message-item:hover {
  transform: translateY(-2px);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.message-avatar {
  margin-right: 16px;
}
.avatar-circle {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24px;
}
.message-content-wrapper {
  flex: 1;
}
.message-header {
  display: flex;
  align-items: center;
  margin-bottom: 8px;
  gap: 12px;
}
.message-sender {
  font-weight: 600;
  color: #1f2937;
  font-size: 16px;
}
.message-status {
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 12px;
}
.message-status.delivered {
  background-color: #dbeafe;
  color: #1d4ed8;
}
.message-status.read {
  background-color: #dcfce7;
  color: #15803d;
}
.message-status.sent {
  background-color: #fef3c7;
  color: #92400e;
}
.message-time {
  font-size: 12px;
  color: #6b7280;
  margin-left: auto;
}
.message-text {
  color: #374151;
  line-height: 1.5;
  margin-bottom: 12px;
}
.message-attachment {
  background-color: #f9fafb;
  border-radius: 8px;
  padding: 12px;
  margin-bottom: 12px;
  border: 1px solid #e5e7eb;
}
.attachment-preview {
  display: flex;
  align-items: center;
  gap: 12px;
}
.attachment-icon {
  font-size: 24px;
}
.attachment-info {
  display: flex;
  flex-direction: column;
}
.attachment-name {
  font-weight: 500;
  color: #1f2937;
}
.attachment-size {
  font-size: 12px;
  color: #6b7280;
}
.message-reactions {
  display: flex;
  gap: 8px;
  margin-top: 8px;
}
.reaction {
  background-color: #f3f4f6;
  padding: 4px 8px;
  border-radius: 12px;
  font-size: 12px;
  color: #4b5563;
}
</style>

src/utils/messageExportService.ts

javascript 复制代码
// messageExportService.ts

import { snapdom } from '@zumer/snapdom'
import { jsPDF } from 'jspdf'

// 图片质量配置
const IMAGE_QUALITY = 0.95
const IMAGE_FORMAT = 'image/png' as const

/**
 * 将 DOM 元素转换为图片
 */
export async function captureElementToImage(element: HTMLElement, quality: number = IMAGE_QUALITY): Promise<string> {
  console.log('开始截图...')

  // 保存原始样式
  const originalOverflow = element.style.overflow
  const originalHeight = element.style.height
  const originalMaxHeight = element.style.maxHeight

  // 临时设置样式,确保完整截图
  element.style.overflow = 'visible'
  element.style.height = 'auto'
  element.style.maxHeight = 'none'

  try {
    // 核心:使用 snapdom 进行截图
    const capture = await snapdom(element, {
      scale: 2, // 2倍清晰度
      quality: quality,
      width: Number(element.offsetWidth) / 2,
      height: Number(element.scrollHeight) / 2
    })

    // 优先使用 toPng()
    const imgElement = await capture.toPng()
    const dataUrl = imgElement.src
    console.log(dataUrl, 'dataUrl')

    // 验证数据有效性
    if (!dataUrl || dataUrl.length < 100) {
      console.log('toPng 返回无效,尝试 toCanvas...')
      const canvas = await capture.toCanvas()
      return canvas.toDataURL(IMAGE_FORMAT, quality)
    }

    console.log('截图成功,大小:', (dataUrl.length / 1024).toFixed(2), 'KB')
   downloadBase64(dataUrl);
    return dataUrl
  } finally {
    // 恢复原始样式
    element.style.overflow = originalOverflow
    element.style.height = originalHeight
    element.style.maxHeight = originalMaxHeight
  }
}

// Base64 转 Blob 并下载
function downloadBase64(dataUrl:any, fileName = 'screenshot.png') {
  try {
    // 提取 MIME 类型和纯 Base64 数据
    const [metadata, base64Data] = dataUrl.split(',');
    const mimeType = metadata!.match(/:(.*?);/)[1]; // 如 "image/png"

    // 解码 Base64
    const binaryString = atob(base64Data);
    const bytes = new Uint8Array(binaryString.length);
    for (let i = 0; i < binaryString.length; i++) {
      bytes[i] = binaryString.charCodeAt(i);
    }

    // 生成 Blob 并下载
    const blob = new Blob([bytes], { type: mimeType });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = fileName;
    a.click();
    
    // 清理资源
    setTimeout(() => {
      URL.revokeObjectURL(url);
      document.body.removeChild(a);
    }, 100);
  } catch (error) {
    console.error('下载失败:', error);
  }
}

// 尺寸常量
const A4_WIDTH_MM = 210
const A4_HEIGHT_MM = 297
const PDF_MARGIN_MM = 10
const PDF_CONTENT_WIDTH_MM = A4_WIDTH_MM - PDF_MARGIN_MM * 2 // 190mm
const PDF_CONTENT_HEIGHT_MM = A4_HEIGHT_MM - PDF_MARGIN_MM * 2 // 277mm

// 1mm = 3.7795275590551 像素(96 DPI)
const MM_TO_PX = 3.7795275590551

// 分页后的图片数据
interface PageImageData {
  dataUrl: string
  width: number
  height: number
}

/**
 * 将长图片分割成多个 A4 页面
 */
export async function splitImageIntoPages(imageDataUrl: string): Promise<PageImageData[]> {
  return new Promise((resolve, reject) => {
    const img = new Image()
    img.crossOrigin = 'anonymous'

    img.onload = () => {
      const pages: PageImageData[] = []
      const originalWidth = img.width
      const originalHeight = img.height

      // 将 A4 内容区域转换为像素(考虑 scale=2)
      const pageContentHeightPx = Math.floor(
        PDF_CONTENT_HEIGHT_MM * MM_TO_PX * 2 // scale=2
      )
      const pageContentWidthPx = Math.floor(PDF_CONTENT_WIDTH_MM * MM_TO_PX * 2)

      // 计算缩放比例(图片宽度适配页面宽度)
      const widthScale = pageContentWidthPx / originalWidth
      const scaledHeight = originalHeight * widthScale

      // 计算总页数
      const totalPages = Math.ceil(scaledHeight / pageContentHeightPx)

      console.log(`原始尺寸: ${originalWidth}x${originalHeight}px`)
      console.log(`缩放后高度: ${scaledHeight}px, 总页数: ${totalPages}`)

      // 逐页裁剪
      for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
        const startY = pageIndex * pageContentHeightPx
        const endY = Math.min(startY + pageContentHeightPx, scaledHeight)
        const currentPageHeight = Math.floor(endY - startY)

        // 计算源图片对应的区域
        const sourceStartY = startY / widthScale
        const sourceHeight = currentPageHeight / widthScale

        // 创建新 Canvas
        const canvas = document.createElement('canvas')
        const ctx = canvas.getContext('2d')!

        canvas.width = pageContentWidthPx
        canvas.height = currentPageHeight

        // 高质量渲染
        ctx.imageSmoothingEnabled = true
        ctx.imageSmoothingQuality = 'high'

        // 绘制当前页内容
        ctx.drawImage(
          img,
          0,
          sourceStartY, // 源图片起始位置
          originalWidth,
          sourceHeight, // 源图片尺寸
          0,
          0, // 目标起始位置
          pageContentWidthPx,
          currentPageHeight // 目标尺寸
        )

        // 转换为 data URL
        const pageDataUrl = canvas.toDataURL(IMAGE_FORMAT, IMAGE_QUALITY)

        pages.push({
          dataUrl: pageDataUrl,
          width: pageContentWidthPx,
          height: currentPageHeight
        })

        console.log(`第 ${pageIndex + 1}/${totalPages} 页处理完成`)
      }
      console.log(pages, 'pages')

      resolve(pages)
    }

    img.onerror = () => reject(new Error('图片加载失败'))
    img.src = imageDataUrl
  })
}

/**
 * 从分页图片创建 PDF
 */
export function createPdfFromPages(pages: PageImageData[]): jsPDF {
  const pdf = new jsPDF({
    orientation: 'portrait',
    unit: 'mm',
    format: 'a4',
    compress: true // 启用压缩,减小文件体积
  })

  if (pages.length === 0) {
    throw new Error('没有可添加的页面')
  }

  pages.forEach((page, index) => {
    // 第一页直接用,后续需要 addPage
    if (index > 0) {
      pdf.addPage()
    }

    // 像素转毫米(考虑 scale=2)
    const scaleFactor = 2
    const pageHeightMm = page.height / MM_TO_PX / scaleFactor

    // 图片适配内容区域宽度
    const finalWidth = PDF_CONTENT_WIDTH_MM // 190mm
    const finalHeight = pageHeightMm

    // 位置:左上角对齐,保留 10mm 边距
    const x = PDF_MARGIN_MM
    const y = PDF_MARGIN_MM

    console.log(`添加第 ${index + 1} 页: ${finalWidth}x${finalHeight.toFixed(2)}mm`)

    // 添加图片到 PDF
    pdf.addImage(page.dataUrl, 'PNG', x, y, finalWidth, finalHeight)
  })

  return pdf
}

interface ExportConfig {
  targetSelector: string // CSS 选择器
  filename?: string // 文件名
  quality?: number // 图片质量
}

/**
 * 主导出函数
 */
export async function exportMessagesToPdf(config: ExportConfig): Promise<void> {
  const { targetSelector, filename = 'messages.pdf', quality = IMAGE_QUALITY } = config

  console.log('=== 开始导出 PDF ===')

  // 1. 获取目标元素
  const element = document.querySelector(targetSelector) as HTMLElement
  if (!element) {
    throw new Error(`元素未找到: ${targetSelector}`)
  }

  console.log('元素尺寸:', {
    width: element.offsetWidth,
    height: element.scrollHeight
  })

  // 2. DOM 截图
  const imageDataUrl = await captureElementToImage(element, quality)
  console.log('截图完成,大小:', (imageDataUrl.length / 1024).toFixed(2), 'KB')

  // 3. 图片分页
  const pages = await splitImageIntoPages(imageDataUrl);
  console.log(`分页完成,共 ${pages.length} 页`);

  // 4. 创建 PDF
  const pdf = createPdfFromPages(pages);

  // 5. 保存文件
  pdf.save(filename);
  console.log('=== 导出完成 ===')
}

参考https://blog.csdn.net/2401_86373285/article/details/155563431?utm_medium=distribute.pc_relevant.none-task-blog-2~default~baidujs_utm_term~default-1-155563431-blog-150078569.235^v43^pc_blog_bottom_relevance_base1&spm=1001.2101.3001.4242.2&utm_relevant_index=3

相关推荐
今夕资源网2 小时前
PDF与图片在线处理工具纯HTML网页源码 PDF 多功能魔方
pdf·pdf在线处理
成为大佬先秃头2 小时前
渐进式JavaScript框架:Vue 组件
前端·javascript·vue.js
赵庆明老师2 小时前
uniapp 微信小程序页面JS模板
javascript·微信小程序·uni-app
程序员勾践2 小时前
前端仅传path路径给后端,避免攻击
前端
登山人在路上2 小时前
Vue 2 中响应式失效的常见情况
开发语言·前端·javascript
董世昌412 小时前
创建对象的方法有哪些?
开发语言·前端
问道飞鱼2 小时前
【前端知识】前端项目不同构建模式的差异
前端·webpack·构建·开发模式·生产模式
be or not to be2 小时前
CSS 布局机制与盒模型详解
前端·css
海市公约2 小时前
JavaScript零基础入门指南:从语法到实战的核心知识点解析
javascript·ecmascript·前端开发·dom·bom·定时器与事件·js语法实战