诺言开票软件
75.81MB · 2025-11-10
在当今的 Web 应用中,AI 已经从“概念”走向“落地”。无论是帮助用户快速解决问题,还是提升产品交互体验,一个轻量、美观、可定制的 AI 聊天助手都能极大增强用户体验。
本文将带你从零开始,使用 Vue 3 + Composition API + TypeScript 构建一个功能完整的 悬浮式 AI 聊天助手组件,支持:
我们将以实战为导向,分享每一个关键环节的实现细节,并总结开发过程中遇到的真实问题与解决方案。
| 技术 | 版本/说明 |
|---|---|
| Vue | 3.4+ (Composition API) |
| TypeScript | 5.x |
| Vite | 5.x |
| Tailwind CSS | 3.x |
| Axios | 1.x |
| Web Speech API | 浏览器原生支持 |
src/
├── components/
│ └── AIAssistant.vue
├── composables/
│ ├── useAIChat.ts
│ └── useLocalStorage.ts
├── utils/
│ └── markdownRenderer.ts
└── types/
└── ai.d.ts
types/ai.d.ts)// types/ai.d.ts
export interface Message {
id: string;
content: string;
role: 'user' | 'assistant';
timestamp: Date;
}
export interface RequestConfig {
url: string;
headers?: Record<string, string>;
params?: Record<string, any>;
timeout?: number;
retryCount?: number;
}
useAIChat 组合式函数我们封装一个通用的组合式逻辑来管理聊天状态和请求流程。
composables/useAIChat.tsimport { ref, computed } from 'vue';
import axios from 'axios';
import type { Message, RequestConfig } from '@/types/ai';
export function useAIChat(config: RequestConfig) {
const messages = ref<Message[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
const addMessage = (message: Omit<Message, 'id' | 'timestamp'>) => {
messages.value.push({
...message,
id: Date.now().toString(),
timestamp: new Date(),
});
};
const sendMessage = async (content: string) => {
if (!content.trim()) return;
addMessage({ content, role: 'user' });
isLoading.value = true;
error.value = null;
try {
const response = await axios.post(
config.url,
{ prompt: content },
{
headers: config.headers,
params: config.params,
timeout: config.timeout || 10000,
// 重试机制
retry: config.retryCount || 3,
}
);
addMessage({
content: response.data.response || '回复内容获取失败',
role: 'assistant',
});
} catch (err: any) {
error.value = err.message || '网络请求失败,请稍后重试';
console.error(err);
} finally {
isLoading.value = false;
}
};
const clearMessages = () => {
messages.value = [];
};
return {
messages,
isLoading,
error,
sendMessage,
clearMessages,
};
}
<!-- components/AIAssistant.vue -->
<template>
<div class="fixed bottom-6 right-6 z-50">
<!-- 悬浮按钮 -->
<button
@click="togglePanel"
class="w-14 h-14 rounded-full bg-indigo-600 text-white flex items-center justify-center shadow-lg hover:bg-indigo-700 transition-all duration-200 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
aria-label="打开 AI 助手"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707-.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</button>
<!-- 聊天面板 -->
<transition name="slide-fade">
<div
v-if="isOpen"
class="absolute bottom-20 right-6 w-80 max-w-xs bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-in-out"
>
<!-- 头部 -->
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-sm font-semibold text-gray-800">智能助手</h3>
<button @click="closePanel" class="text-gray-500 hover:text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 内容区 -->
<div class="h-96 overflow-y-auto p-4 space-y-4">
<div v-for="msg in chat.messages" :key="msg.id" class="flex flex-col">
<div
:class="[
'px-4 py-2 rounded-lg max-w-xs',
msg.role === 'user'
? 'bg-blue-100 text-blue-800 ml-auto'
: 'bg-gray-100 text-gray-800 mr-auto'
]"
>
<div v-html="renderMarkdown(msg.content)" />
</div>
<span class="text-xs text-gray-500 mt-1">{{ formatTime(msg.timestamp) }}</span>
</div>
<!-- 加载状态 -->
<div v-if="chat.isLoading" class="flex justify-start">
<div class="flex items-center space-x-2 px-4 py-2 bg-gray-100 rounded-lg">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
<span class="text-gray-600 text-sm">正在思考...</span>
</div>
</div>
<!-- 错误提示 -->
<div v-if="chat.error" class="text-red-500 text-sm p-2 bg-red-50 rounded">
{{ chat.error }}
</div>
</div>
<!-- 输入区域 -->
<div class="p-4 border-t border-gray-200">
<div class="flex items-center space-x-2">
<button
@click="handleVoiceInput"
class="p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100"
title="语音输入"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 013-3h.01C9.3 2 12 4.7 12 8v.01a3 3 0 01-3 3z" />
</svg>
</button>
<input
v-model="inputText"
@keypress.enter="send"
placeholder="请输入消息..."
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
maxlength="500"
/>
<button
@click="send"
:disabled="!inputText.trim() || chat.isLoading"
class="p-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
title="发送"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useAIChat } from '@/composables/useAIChat';
import { useLocalStorage } from '@/composables/useLocalStorage';
const inputText = ref('');
const isOpen = ref(false);
// 配置 AI 请求
const config: RequestConfig = {
url: '/api/chat', // 替换为你自己的 API 地址
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
params: {
model: 'gpt-3.5-turbo',
},
timeout: 15000,
retryCount: 3,
};
// 使用组合式逻辑
const chat = useAIChat(config);
// 控制面板显示
const togglePanel = () => {
isOpen.value = !isOpen.value;
};
const closePanel = () => {
isOpen.value = false;
};
const send = () => {
if (!inputText.value.trim()) return;
chat.sendMessage(inputText.value);
inputText.value = '';
};
const handleVoiceInput = () => {
if (!('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
alert('浏览器不支持语音识别');
return;
}
const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
recognition.lang = 'zh-CN';
recognition.continuous = false;
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
inputText.value = transcript;
};
recognition.onerror = () => {
alert('语音识别失败');
};
recognition.start();
};
const renderMarkdown = (text: string) => {
// 简单的 Markdown 渲染(可替换为 marked 或 remark)
return text
.replace(/**(.*?)**/g, '<strong>$1</strong>')
.replace(/*(.*?)*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>');
};
const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
</script>
<style scoped>
.slide-fade-enter-active {
transition: all 0.3s ease;
}
.slide-fade-leave-active {
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 0);
}
.slide-fade-enter, .slide-fade-leave-to {
transform: translateY(20px);
opacity: 0;
}
</style>
@media 查询检测设备类型,隐藏语音按钮const isSpeechSupported = 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window;
vue-virtual-scroll-list)IntersectionObserver 懒加载旧消息// 在 useAIChat 中添加
const MAX_MESSAGES = 50;
if (messages.value.length > MAX_MESSAGES) {
messages.value = messages.value.slice(-MAX_MESSAGES);
}
throttle 限制每秒最多 1 次请求import { debounce } from 'lodash';
const debouncedSend = debounce(send, 500);
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
},
},
},
});
| 功能 | 实现方式 |
|---|---|
| Markdown 支持 | 使用 marked.js 或 remark |
| 文件上传 | 添加 <input type="file"> 并上传 base64 |
| 多轮对话上下文 | 保存会话 ID 到 localStorage |
| 主题切换 | 使用 Tailwind 的 dark: 类或 CSS 变量 |
| 消息撤回 | 添加“撤销”按钮并删除最后一条消息 |
composables 封装业务逻辑。ref 和 computed。aria-label、键盘支持。localStorage 存储聊天历史,提升用户体验。一个优秀的 AI 聊天助手,不仅是技术的体现,更是对用户需求的深度理解。通过本次实战,我们不仅实现了功能,更掌握了:
希望这篇教程能帮你快速构建属于自己的 AI 助手,也欢迎你在评论区分享你的改造方案!