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('=== 导出完成 ===')
}