AI聊天系统 实战:打造优雅的聊天记录复制与批量下载机制

在这里插入图片描述

在这里插入图片描述

最近在做一个 AI 多模态项目的前端开发,遇到了一个挺有意思的需求:用户希望能够方便地复制 AI 的回答内容,并且支持批量导出聊天记录为文档或图片。这个功能看似简单,但要做得体验好、交互流畅,还是有不少细节需要打磨的。

今天就和大家分享一下这个功能的完整实现过程,希望能给正在做类似需求的朋友一些参考。

需求分析

在动手之前,先明确一下产品需求:

【聊天消息复制功能】

  • 用户点击复制按钮,将 AI 回答内容复制到剪贴板
  • 复制成功后显示"已复制"提示,2 秒后消失
  • 使用原生 Clipboard API,兼容现代浏览器

【聊天记录批量下载功能】

这个需求稍微复杂一些,分为几个步骤:

  1. 进入选择模式:点击消息下方的下载图标,进入批量选择模式
  2. 多选对话:所有 AI 回复展示复选框,其他功能图标隐藏,默认选中当前项
  3. 固定浮窗:右侧弹出固定浮窗,显示已选条数、下载预览按钮和取消按钮
  4. 预览弹窗:点击下载预览,弹出预览弹窗,展示已选问答(包含思考过程)
  5. 导出功能:支持一键导出为图片(PNG)或文本文档(TXT)

用一句话总结:点击下载 → 进入选择模式 → 勾选对话 → 预览 → 导出图片/文档

技术选型

这个项目使用的技术栈是:

  • Vue 3.5 + Composition API
  • Element Plus UI 组件库
  • Pinia 状态管理
  • Vite 构建工具

针对这个功能,额外引入了:

  • html2canvas:用于将 DOM 转换为图片
npm install html2canvas

实现思路

1. 工具函数封装

首先,我把常用的文件操作封装成了工具函数,方便复用。创建 utils/fileUtil.js

/**
* 文件操作工具函数
* 提供文件下载、文本下载和剪贴板复制功能
*/
/**
* 动态创建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 组件中,添加选择模式的支持:

<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>

模板部分根据模式动态切换显示内容:

3. 创建下载浮窗组件

创建 DownloadPanel.vue,这是右侧固定显示的浮窗:


<script setup>
defineProps({
    show: Boolean,
    selectedCount: Number
})
const emit = defineEmits(['preview', 'cancel'])
const handlePreview = () => {
    emit('preview');
};
const handleCancel = () => {
    emit('cancel');
};
</script>

这个浮窗的设计考虑了几点:

  • 固定在右侧居中位置,不影响主内容区域
  • 带有滑入滑出动画,体验更流畅
  • 实时显示已选条数,给用户明确反馈

4. 创建预览弹窗组件

这是最核心的部分,DownloadPreviewModal.vue


<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>

这里的关键点:

  1. 文本导出:格式化输出,包含标题、分隔线、时间戳等,让导出的文本更易读
  2. 图片导出:使用 html2canvasscale: 2 参数提高清晰度,适合高分屏
  3. 异步处理:下载操作都是异步的,使用 loading 状态提升用户体验

5. 主视图状态管理

最后在 ChatView.vue 中整合所有功能(仅展示状态管理核心逻辑):

// 批量下载选择模式
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 访问。

效果展示

完成后的功能流程非常流畅:

  1. 点击下载图标 → 页面进入选择模式
  2. 勾选想要的对话 → 右侧浮窗实时显示已选数量
  3. 点击下载预览 → 弹窗展示格式化的对话内容
  4. 选择导出格式 → 一键下载到本地

整个交互符合用户直觉,没有多余的步骤。

总结

这次的功能开发让我对 Vue 3 的组件通信、状态管理有了更深的理解。几个心得:

  1. 工具函数先行:把通用逻辑提取成工具函数,提高代码复用性
  2. 组件职责单一:每个组件只做一件事,降低耦合度
  3. 状态提升:选择状态放在父组件管理,子组件通过 props 和 emit 通信
  4. 用户体验优先:加载状态、过渡动画、错误提示一个都不能少

多模态Ai项目全流程开发中,从需求分析,到Ui设计,前后端开发,部署上线,感兴趣打开链接(带项目功能演示)多模态AI项目开发中…

完整的代码已经在项目中跑了一段时间了,稳定性还不错。如果你也在做类似的功能,希望这篇文章能帮到你。

有什么问题欢迎在评论区讨论!

posted @ 2025-11-08 21:34  clnchanpin  阅读(8)  评论(0)    收藏  举报