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 转换为图片
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>

这里的关键点:

  1. 文本导出:格式化输出,包含标题、分隔线、时间戳等,让导出的文本更易读
  2. 图片导出 :使用 html2canvasscale: 2 参数提高清晰度,适合高分屏
  3. 异步处理:下载操作都是异步的,使用 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 访问。

效果展示

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

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

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

总结

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

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

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

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

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

相关推荐
小小弯_Shelby3 小时前
uniApp App内嵌H5打开内部链接,返回手势(左滑右滑页面)会直接关闭H5项目
前端·uni-app
IT_陈寒3 小时前
SpringBoot性能飞跃:5个关键优化让你的应用吞吐量提升300%
前端·人工智能·后端
加洛斯3 小时前
Vue 知识篇(2):浅谈Vue中的DOM与VNode
前端·javascript·vue.js
kunge1v53 小时前
学习爬虫第三天:数据提取
前端·爬虫·python·学习
聚客AI4 小时前
系统提示的“消亡”?上下文工程正在重新定义人机交互规则
图像处理·人工智能·pytorch·语言模型·自然语言处理·chatgpt·gpt-3
可爱的秋秋啊4 小时前
简单网站编写
开发语言·前端
红纸2814 小时前
Subword算法之WordPiece、Unigram与SentencePiece
人工智能·python·深度学习·神经网络·算法·机器学习·自然语言处理
golang学习记4 小时前
Crush:新一代基于Go语言构建的开源 AI 编程CLI工具
人工智能
一车小面包4 小时前
Subword-Based Tokenization策略之BPE与BBPE
人工智能·自然语言处理