

最近在做一个 AI 多模态项目的前端开发,遇到了一个挺有意思的需求:用户希望能够方便地复制 AI 的回答内容,并且支持批量导出聊天记录为文档或图片。这个功能看似简单,但要做得体验好、交互流畅,还是有不少细节需要打磨的。
今天就和大家分享一下这个功能的完整实现过程,希望能给正在做类似需求的朋友一些参考。
需求分析
在动手之前,先明确一下产品需求:
【聊天消息复制功能】
- 用户点击复制按钮,将 AI 回答内容复制到剪贴板
- 复制成功后显示"已复制"提示,2 秒后消失
- 使用原生 Clipboard API,兼容现代浏览器
【聊天记录批量下载功能】
这个需求稍微复杂一些,分为几个步骤:
- 进入选择模式:点击消息下方的下载图标,进入批量选择模式
- 多选对话:所有 AI 回复展示复选框,其他功能图标隐藏,默认选中当前项
- 固定浮窗:右侧弹出固定浮窗,显示已选条数、下载预览按钮和取消按钮
- 预览弹窗:点击下载预览,弹出预览弹窗,展示已选问答(包含思考过程)
- 导出功能:支持一键导出为图片(PNG)或文本文档(TXT)
用一句话总结:点击下载 → 进入选择模式 → 勾选对话 → 预览 → 导出图片/文档
技术选型
这个项目使用的技术栈是:
- Vue 3.5 + Composition API
- Element Plus UI 组件库
- Pinia 状态管理
- Vite 构建工具
针对这个功能,额外引入了:
- html2canvas:用于将 DOM 转换为图片
bash
npm install html2canvas
实现思路
1. 工具函数封装
首先,我把常用的文件操作封装成了工具函数,方便复用。创建 utils/fileUtil.js
:
javascript
/**
* 文件操作工具函数
* 提供文件下载、文本下载和剪贴板复制功能
*/
/**
* 动态创建a标签下载文件
* @param {string} url - 文件下载链接
* @param {string} filename - 下载文件名
*/
export const downloadFile = (url, filename = '') => {
try {
const link = document.createElement('a')
link.href = url
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (error) {
console.error('文件下载失败:', error)
throw new Error('文件下载失败')
}
}
/**
* 将文本内容转换为Blob并下载
* @param {string} content - 要下载的文本内容
* @param {string} filename - 文件名(包含扩展名)
* @param {string} mimeType - MIME类型
*/
export const downloadText = (content, filename, mimeType = 'text/plain') => {
try {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
downloadFile(url, filename)
// 释放URL内存
setTimeout(() => URL.revokeObjectURL(url), 100)
} catch (error) {
console.error('文本下载失败:', error)
throw new Error('文本下载失败')
}
}
/**
* 复制文本到剪贴板
* @param {string} text - 要复制的文本
* @returns {Promise<boolean>} 复制是否成功
*/
export const copyToClipboard = async (text) => {
try {
// 优先使用现代剪贴板API
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return true
}
return false
} catch (error) {
console.error('复制到剪贴板失败:', error)
return false
}
}
这里有几个细节值得注意:
downloadFile
使用动态创建<a>
标签的方式触发下载,兼容性好downloadText
使用 Blob 对象处理文本内容,支持自定义 MIME 类型copyToClipboard
优先使用现代 Clipboard API,并做了安全上下文检查
2. 改造消息组件
在 ChatMessage.vue
组件中,添加选择模式的支持:
vue
<script setup>
import { copyToClipboard } from '@/utils/fileUtil'
import { ElMessage } from 'element-plus'
const props = defineProps({
message: {
type: Object,
required: true
},
// 是否处于批量下载选择模式
isSelectMode: {
type: Boolean,
default: false
},
// 是否被选中
isSelected: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['toggle-select', 'enter-select-mode'])
// 复制消息内容
const copyMessage = async () => {
try {
const success = await copyToClipboard(props.message.content)
if (success) {
ElMessage.success('已复制')
} else {
ElMessage.error('复制失败,请重试')
}
} catch (err) {
console.error('复制失败:', err)
ElMessage.error('复制失败')
}
}
// 点击下载按钮,进入批量下载选择模式
const downloadMessage = () => {
emit('enter-select-mode', props.message)
}
// 切换选中状态
const toggleSelect = () => {
emit('toggle-select', props.message)
}
</script>
模板部分根据模式动态切换显示内容:
vue
<template>
<div class="message-actions">
<template v-if="isSelectMode">
<el-checkbox
:model-value="isSelected"
@change="toggleSelect"
class="select-checkbox">
选择此条对话
</el-checkbox>
</template>
<template v-else>
<el-icon @click="copyMessage" title="复制">
<CopyDocument />
</el-icon>
<el-icon @click="toggleVoice" title="AI语音朗读">
<Microphone />
</el-icon>
<el-icon @click="favoriteMessage" title="收藏">
<Star />
</el-icon>
<el-icon @click="downloadMessage" title="下载">
<Download />
</el-icon>
</template>
</div>
</template>
3. 创建下载浮窗组件
创建 DownloadPanel.vue
,这是右侧固定显示的浮窗:
vue
<template>
<transition name="slide-left">
<div v-if="show" class="download-panel">
<div class="panel-header">
<h3>批量下载</h3>
</div>
<div class="panel-content">
<div class="selected-count">
已选择 <span class="count">{{ selectedCount }}</span> 条对话
</div>
<div class="panel-actions">
<el-button
type="primary"
@click="handlePreview"
:disabled="selectedCount === 0">
<el-icon><View /></el-icon>
下载预览
</el-button>
<el-button @click="handleCancel">取消</el-button>
</div>
</div>
</div>
</transition>
</template>
<script setup>
defineProps({
show: Boolean,
selectedCount: Number
})
const emit = defineEmits(['preview', 'cancel'])
const handlePreview = () => {
emit('preview');
};
const handleCancel = () => {
emit('cancel');
};
</script>
<style lang="scss" scoped>
.download-panel {
position: fixed;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 280px;
background: #FFFFFF;
border-radius: 12px 0 0 12px;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.1);
padding: 24px;
z-index: 1000;
}
// 滑入滑出动画
.slide-left-enter-active,
.slide-left-leave-active {
transition: transform 0.3s ease;
}
.slide-left-enter-from,
.slide-left-leave-to {
transform: translateX(100%) translateY(-50%);
}
</style>
这个浮窗的设计考虑了几点:
- 固定在右侧居中位置,不影响主内容区域
- 带有滑入滑出动画,体验更流畅
- 实时显示已选条数,给用户明确反馈
4. 创建预览弹窗组件
这是最核心的部分,DownloadPreviewModal.vue
:
vue
<template>
<el-dialog
v-model="visible"
title="下载预览"
width="80%">
<div class="preview-content" ref="previewRef">
<div class="preview-header">
<h2>对话记录</h2>
<div class="preview-meta">
<span>共 {{ messages.length }} 条对话</span>
<span>{{ formatDate(new Date()) }}</span>
</div>
</div>
<div class="preview-messages">
<div v-for="msg in messages" :key="msg.id">
<div v-if="msg.userMessage" class="user-section">
<div class="user-content">{{ msg.userMessage }}</div>
</div>
<div v-if="msg.aiMessage" class="ai-section">
<div v-if="msg.thinking" class="thinking-section">
<div class="thinking-label">思考过程:</div>
<div class="thinking-content">{{ msg.thinking }}</div>
</div>
<AiAnswer :content="msg.aiMessage" />
</div>
<div class="divider"></div>
</div>
</div>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="downloadAsText"
:loading="isDownloading">
<el-icon><Document /></el-icon>
下载文本文档
</el-button>
<el-button
type="primary"
@click="downloadAsImage"
:loading="isDownloading">
<el-icon><Picture /></el-icon>
下载图片
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import html2canvas from 'html2canvas'
import { downloadText, downloadFile } from '@/utils/fileUtil'
import { formatDate } from '@/utils/dateUtil'
const props = defineProps({
visible: Boolean,
messages: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:visible'])
const previewRef = ref(null)
const isDownloading = ref(false)
const handleClose = () => {
emit('update:visible', false)
}
// 下载为文本文档
const downloadAsText = async () => {
try {
isDownloading.value = true
let content = '对话记录\n'
content += `共 ${props.messages.length} 条对话\n`
content += `导出时间:${formatDate(new Date())}\n`
content += '='.repeat(50) + '\n\n'
props.messages.forEach((msg, index) => {
content += `对话 ${index + 1}\n`
content += '-'.repeat(50) + '\n'
if (msg.userMessage) {
content += `用户提问:\n${msg.userMessage}\n\n`
}
if (msg.thinking) {
content += `思考过程:\n${msg.thinking}\n\n`
}
if (msg.aiMessage) {
content += `AI回答:\n${msg.aiMessage}\n\n`
}
content += '\n'
})
const filename = `chat_history_${Date.now()}.txt`
downloadText(content, filename)
ElMessage.success('文本文档下载成功')
} catch (error) {
console.error('下载文本文档失败:', error)
ElMessage.error('下载失败,请重试')
} finally {
isDownloading.value = false
}
}
// 下载为图片
const downloadAsImage = async () => {
try {
isDownloading.value = true
if (!previewRef.value) {
throw new Error('预览内容未加载')
}
// 使用 html2canvas 将内容转换为图片
const canvas = await html2canvas(previewRef.value, {
backgroundColor: '#FFFFFF',
scale: 2, // 提高清晰度
useCORS: true,
logging: false
})
// 转换为 Blob 并下载
canvas.toBlob((blob) => {
if (!blob) {
throw new Error('图片生成失败')
}
const url = URL.createObjectURL(blob)
const filename = `chat_history_${Date.now()}.png`
downloadFile(url, filename)
setTimeout(() => URL.revokeObjectURL(url), 100)
ElMessage.success('图片下载成功')
}, 'image/png')
} catch (error) {
console.error('下载图片失败:', error)
ElMessage.error('下载失败,请重试')
} finally {
isDownloading.value = false
}
}
</script>
这里的关键点:
- 文本导出:格式化输出,包含标题、分隔线、时间戳等,让导出的文本更易读
- 图片导出 :使用
html2canvas
的scale: 2
参数提高清晰度,适合高分屏 - 异步处理:下载操作都是异步的,使用 loading 状态提升用户体验
5. 主视图状态管理
最后在 ChatView.vue
中整合所有功能(仅展示状态管理核心逻辑):
javascript
// 批量下载选择模式
const isSelectMode = ref(false)
// 已选择的消息(使用 Set 存储消息 ID)
const selectedMessages = ref(new Set())
// 下载预览弹窗状态
const showDownloadPreview = ref(false)
// 假设 currentMessages 是聊天记录的响应式数组
const currentMessages = ref([
// ... 消息对象
]);
// 进入选择模式
const enterSelectMode = (message) => {
isSelectMode.value = true
// 默认选中触发的消息
selectedMessages.value.clear()
selectedMessages.value.add(message.id)
}
// 退出选择模式
const exitSelectMode = () => {
isSelectMode.value = false
selectedMessages.value.clear()
}
// 切换消息选中状态
const toggleSelectMessage = (message) => {
if (selectedMessages.value.has(message.id)) {
selectedMessages.value.delete(message.id)
} else {
selectedMessages.value.add(message.id)
}
}
// 准备预览消息数据
const previewMessages = computed(() => {
const messages = []
const allMessages = currentMessages.value
const selectedIds = Array.from(selectedMessages.value)
for (let i = 0; i < allMessages.length; i++) {
const msg = allMessages[i]
// 只处理被选中的 AI 消息
if (msg.type === 'ai' && selectedIds.includes(msg.id)) {
// 查找对应的用户消息(前一条)
const userMsg = i > 0 ? allMessages[i - 1] : null
messages.push({
id: msg.id,
userMessage: userMsg?.type === 'user' ? userMsg.content : '',
aiMessage: msg.content,
thinking: msg.thinking || ''
})
}
}
return messages
})
踩过的坑
1. html2canvas 清晰度问题
最开始导出的图片很模糊,后来发现是高分屏的问题。解决方法是设置 scale: 2
,将画布放大2倍再导出。
2. 内存泄漏问题
使用 URL.createObjectURL()
创建的临时 URL 需要手动释放,否则会造成内存泄漏。记得用 URL.revokeObjectURL()
清理。
3. 异步操作的状态管理
下载操作是异步的,需要用 isDownloading
状态控制按钮的 loading 效果,防止用户重复点击。
4. Set 对象的响应式
Vue 3 的 ref
包裹 Set
对象后,需要注意修改 Set 内容时要通过 .value
访问。
效果展示
完成后的功能流程非常流畅:
- 点击下载图标 → 页面进入选择模式
- 勾选想要的对话 → 右侧浮窗实时显示已选数量
- 点击下载预览 → 弹窗展示格式化的对话内容
- 选择导出格式 → 一键下载到本地
整个交互符合用户直觉,没有多余的步骤。
总结
这次的功能开发让我对 Vue 3 的组件通信、状态管理有了更深的理解。几个心得:
- 工具函数先行:把通用逻辑提取成工具函数,提高代码复用性
- 组件职责单一:每个组件只做一件事,降低耦合度
- 状态提升:选择状态放在父组件管理,子组件通过 props 和 emit 通信
- 用户体验优先:加载状态、过渡动画、错误提示一个都不能少
多模态Ai项目全流程开发中,从需求分析,到Ui设计,前后端开发,部署上线,感兴趣打开链接(带项目功能演示) ,多模态AI项目开发中...
完整的代码已经在项目中跑了一段时间了,稳定性还不错。如果你也在做类似的功能,希望这篇文章能帮到你。
有什么问题欢迎在评论区讨论!