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

相关推荐
WeiXiao_Hyy3 小时前
成为 Top 1% 的工程师
java·开发语言·javascript·经验分享·后端
吃杠碰小鸡4 小时前
高中数学-数列-导数证明
前端·数学·算法
kingwebo'sZone4 小时前
C#使用Aspose.Words把 word转成图片
前端·c#·word
xjt_09014 小时前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农4 小时前
Vue 2.3
前端·javascript·vue.js
夜郎king5 小时前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳5 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵6 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星6 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_6 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js