feat: Export conversation page to PDF (#3941)

This commit is contained in:
shaohuzhang1 2025-08-26 18:38:58 +08:00 committed by GitHub
parent 39fd66e84b
commit 7948e4f78b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 165 additions and 61 deletions

View File

@ -32,6 +32,7 @@
"element-plus": "^2.10.2",
"file-saver": "^2.0.5",
"highlight.js": "^11.11.1",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.1",
"katex": "^0.16.10",
@ -45,6 +46,7 @@
"recorder-core": "^1.3.25011100",
"screenfull": "^6.0.2",
"sortablejs": "^1.15.6",
"svg2pdf.js": "^2.5.0",
"use-element-plus-theme": "^0.0.5",
"vite-plugin-html": "^3.2.2",
"vue": "^3.5.13",

View File

@ -0,0 +1,146 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="$t('chat.preview')"
style="overflow: auto"
width="80%"
:before-close="close"
destroy-on-close
>
<div
v-loading="loading"
style="height: calc(70vh - 150px); overflow-y: auto; display: flex; justify-content: center"
>
<div ref="svgContainerRef"></div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button :loading="loading" @click="exportPDF">{{ $t('chat.exportPDF') }}</el-button>
<el-button
:loading="loading"
type="primary"
@click="
() => {
loading = true
exportJepg()
}
"
>
{{ $t('chat.exportImg') }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import * as htmlToImage from 'html-to-image'
import { ref, nextTick } from 'vue'
import html2Canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
const loading = ref<boolean>(false)
const svgContainerRef = ref()
const dialogVisible = ref<boolean>(false)
const open = (element: HTMLElement | null) => {
dialogVisible.value = true
loading.value = true
if (!element) {
return
}
setTimeout(() => {
nextTick(() => {
htmlToImage
.toSvg(element, { pixelRatio: 1, quality: 1 })
.then((dataUrl) => {
return fetch(dataUrl)
.then((response) => {
return response.text()
})
.then((text) => {
const parser = new DOMParser()
const svgDoc = parser.parseFromString(text, 'image/svg+xml')
const svgElement = svgDoc.documentElement
svgContainerRef.value.appendChild(svgElement)
svgContainerRef.value.style.height = svgElement.scrollHeight + 'px'
})
})
.finally(() => {
loading.value = false
})
})
}, 1)
}
const exportPDF = () => {
loading.value = true
setTimeout(() => {
nextTick(() => {
html2Canvas(svgContainerRef.value, {
logging: false,
})
.then((canvas) => {
const doc = new jsPDF('p', 'mm', 'a4')
// canvas
const imgData = canvas.toDataURL(`image/jpeg`, 1)
// PDF
const pageWidth = doc.internal.pageSize.getWidth()
const pageHeight = doc.internal.pageSize.getHeight()
// PDF
const imgWidth = pageWidth
const imgHeight = (canvas.height * imgWidth) / canvas.width
// PDF
doc.addImage(imgData, 'jpeg', 0, 0, imgWidth, imgHeight)
//
let heightLeft = imgHeight
let position = 0
//
heightLeft -= pageHeight
//
while (heightLeft >= 0) {
position = heightLeft - imgHeight
doc.addPage()
doc.addImage(imgData, 'jpeg', 0, position, imgWidth, imgHeight)
heightLeft -= pageHeight
}
// PDF
doc.save('导出文档.pdf')
return 'ok'
})
.finally(() => {
loading.value = false
})
})
})
}
const exportJepg = () => {
loading.value = true
setTimeout(() => {
nextTick(() => {
html2Canvas(svgContainerRef.value, {
logging: false,
})
.then((canvas) => {
// canvas
const imgData = canvas.toDataURL(`image/jpeg`, 1)
const link = document.createElement('a')
link.download = `webpage-screenshot.jpeg`
link.href = imgData
document.body.appendChild(link)
link.click()
return 'ok'
})
.finally(() => {
loading.value = false
})
})
}, 1)
}
const close = () => {
dialogVisible.value = false
}
defineExpose({ open, close })
</script>
<style lang="scss" scoped></style>

View File

@ -9,6 +9,9 @@ export default {
only20history: 'Showing only the last 20 chats',
question_count: 'Questions',
exportRecords: 'Export Chat History',
exportPDF: 'Export PDF',
exportImg: 'Exporting images',
preview: 'Preview',
chatId: 'Chat ID',
userInput: 'User Input',
quote: 'Quote',

View File

@ -9,6 +9,9 @@ export default {
only20history: '仅显示最近 20 条对话',
question_count: '条提问',
exportRecords: '导出聊天记录',
exportPDF: '导出PDF',
exportImg: '导出图片',
preview: '预览',
chatId: '对话 ID',
chatUserId: '对话用户 ID',
chatUserType: '对话用户类型',

View File

@ -9,6 +9,9 @@ export default {
only20history: '僅顯示最近 20 條對話',
question_count: '條提問',
exportRecords: '導出聊天記錄',
exportPDF: '匯出PDF',
exportImg: '匯出圖片',
preview: '預覽',
chatId: '對話 ID',
userInput: '用戶輸入',
quote: '引用',

View File

@ -1,56 +0,0 @@
import html2Canvas from 'html2canvas'
import jsPDF from 'jspdf'
export const exportToPDF = async (elementId: string, filename = 'document.pdf') => {
const element = document.getElementById(elementId)
if (!element) return
await html2Canvas(element, {
useCORS: true,
allowTaint: true,
logging: false,
scale: 2,
backgroundColor: '#fff',
}).then((canvas: any) => {
const pdf = new jsPDF('p', 'mm', 'a4')
const pageWidth = 190 // 保留边距后的有效宽度
const pageHeight = 277 // 保留边距后的有效高度 //A4大小210mm x 297mm四边各保留10mm的边距显示区域190x277
const imgHeight = (pageHeight * canvas.width) / pageWidth
let renderedHeight = 0
while (renderedHeight < canvas.height) {
const pageCanvas = document.createElement('canvas')
pageCanvas.width = canvas.width
pageCanvas.height = Math.min(imgHeight, canvas.height - renderedHeight)
pageCanvas
.getContext('2d')!
.putImageData(
canvas
.getContext('2d')!
.getImageData(
0,
renderedHeight,
canvas.width,
Math.min(imgHeight, canvas.height - renderedHeight),
),
0,
0,
)
pdf.addImage(
pageCanvas.toDataURL('image/jpeg', 1.0),
'JPEG',
10,
10, // 左边距和上边距
pageWidth,
Math.min(pageHeight, (pageWidth * pageCanvas.height) / pageCanvas.width),
)
renderedHeight += imgHeight
if (renderedHeight < canvas.height) {
pdf.addPage()
}
}
pdf.save(filename)
})
}

View File

@ -139,7 +139,7 @@
<el-dropdown-item @click="exportHTML"
>{{ $t('common.export') }} HTML</el-dropdown-item
>
<el-dropdown-item @click="exportToPDF('chatListId', currentChatName + '.pdf')"
<el-dropdown-item @click="openPDFExport"
>{{ $t('common.export') }} PDF</el-dropdown-item
>
</el-dropdown-menu>
@ -169,7 +169,7 @@
<div class="execution-detail-panel" :resizable="false" collapsible>
<div class="p-16 flex-between border-b">
<h4 class="medium ellipsis" :title="rightPanelTitle">{{ rightPanelTitle }}</h4>
 
<div class="flex align-center">
<span v-if="rightPanelType === 'paragraphDocument'" class="mr-4">
<a
@ -217,6 +217,7 @@
emitConfirm
@confirm="handleResetPassword"
></ResetPassword>
<PdfExport ref="pdfExportRef"></PdfExport>
</div>
</template>
@ -238,12 +239,14 @@ import ParagraphDocumentContent from '@/components/ai-chat/component/knowledge-s
import HistoryPanel from '@/views/chat/component/HistoryPanel.vue'
import { cloneDeep } from 'lodash'
import { getFileUrl } from '@/utils/common'
import { exportToPDF } from '@/utils/htmlToPdf'
import PdfExport from '@/components/pdf-export/index.vue'
useResize()
const pdfExportRef = ref<InstanceType<typeof PdfExport>>()
const { common, chatUser } = useStore()
const router = useRouter()
const openPDFExport = () => {
pdfExportRef.value?.open(document.getElementById('chatListId'))
}
const isCollapse = ref(false)
const isPcCollapse = ref(false)
watch(