UniApp+Vue3接入DeepSeek AI聊天实战
·
一、技术栈与前置准备
1. 核心技术栈
- 框架:UniApp + Vue3 (Setup 语法糖)
- AI 接口:DeepSeek API (deepseek-chat 模型)
- 样式:SCSS
- 特性:响应式布局、跨端适配、异步请求
2. DeepSeek API 准备
- 注册 DeepSeek 开发者账号,获取 API Key https://platform.deepseek.com/
- 了解 DeepSeek Chat Completions 接口文档
- 注意:API Key 切勿前端硬编码(本文为演示方便暂时写死,生产环境需后端代理)
二、核心代码实现
1. DeepSeek API 封装(deepseek.js)
首先封装 DeepSeek 的请求方法,统一处理接口调用逻辑:
// deepseek.js - 稳健版本
export const DEEPSEEK_API_BASE = 'https://api.deepseek.com/v1'
export const DEEPSEEK_MODEL = 'deepseek-chat'
// 生产环境注意:API Key一定要通过后端代理,不要前端写死!
export const DEEPSEEK_API_KEY = '写你的KEY'
export const deepSeekChatStream = async ({
messages,
model = DEEPSEEK_MODEL,
temperature = 0.2,
baseUrl = DEEPSEEK_API_BASE,
apiKey = DEEPSEEK_API_KEY,
maxTokens = 800,
onChunk,
onError,
onComplete
}) => {
return new Promise((resolve, reject) => {
let fullContent = ''
let completed = false
const requestTask = uni.request({
url: `${baseUrl}/chat/completions`,
method: 'POST',
header: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
data: {
model,
messages,
temperature,
max_tokens: maxTokens,
stream: true,
stream_options: {
include_usage: true
}
},
timeout: 60000,
enableChunked: true,
success: (res) => {
if (!completed) {
completed = true
onComplete?.(fullContent)
resolve(fullContent)
}
},
fail: (err) => {
console.error('请求失败:', err)
onError?.(err)
reject(err)
}
})
requestTask.onChunkReceived((res) => {
try {
// 获取 ArrayBuffer 并转换为字符串
let chunk = ''
const data = res.data
// 兼容不同平台的解码方式
if (typeof data === 'string') {
chunk = data
} else if (data instanceof ArrayBuffer) {
// 使用 String.fromCharCode 直接转换
const bytes = new Uint8Array(data)
let str = ''
for (let i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i])
}
// 处理 UTF-8 编码
try {
chunk = decodeURIComponent(escape(str))
} catch (e) {
chunk = str
}
}
// 按行解析 SSE 数据
const lines = chunk.split('\n')
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine || !trimmedLine.startsWith('data:')) continue
const jsonStr = trimmedLine.substring(5).trim()
if (jsonStr === '[DONE]') {
completed = true
onComplete?.(fullContent)
resolve(fullContent)
continue
}
try {
const jsonData = JSON.parse(jsonStr)
const content = jsonData.choices?.[0]?.delta?.content
if (content) {
fullContent += content
onChunk?.(content, fullContent)
}
} catch (parseError) {
console.warn('JSON 解析失败:', jsonStr)
}
}
} catch (error) {
console.error('处理数据块失败:', error)
if (!completed) {
onError?.(error)
}
}
})
return requestTask
})
}
2. 聊天页面完整实现
<template>
<view class="ai-consult-page">
<!-- 聊天内容区域 -->
<scroll-view
class="chat-scroll"
scroll-y
:show-scrollbar="false"
:scroll-with-animation="false"
:scroll-into-view="scrollIntoView"
@scroll="handleScroll"
>
<view class="chat-container">
<!-- 时间显示 -->
<view class="time-badge">{{ currentTimeText }}</view>
<view class="expire-badge" v-if="expireSeconds > 0">剩余问诊时间:{{ expireText }}</view>
<!-- 消息列表 -->
<view v-for="(msg, index) in messageList" :key="msg.id" :id="'msg-' + index" class="message-item" :class="[msg.type === 'user' ? 'user-message' : 'ai-message']">
<!-- AI头像 -->
<view v-if="msg.type === 'ai'" class="avatar-wrapper">
<image class="avatar" src="/static/images/yisheng.png" mode="aspectFill"></image>
</view>
<!-- 消息内容 -->
<view class="message-content">
<!-- 文字消息 -->
<view v-if="!msg.image" class="message-bubble">
<text>{{ msg.content }}</text>
</view>
<!-- 图片消息 -->
<view v-if="msg.image" class="message-image" @tap="previewImage(msg.image)">
<image :src="msg.image" mode="aspectFill" class="chat-image"></image>
</view>
</view>
<!-- 用户头像 -->
<view v-if="msg.type === 'user'" class="avatar-wrapper">
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
</view>
</view>
<!-- AI正在输入提示 -->
<view v-if="aiTyping" class="message-item ai-message">
<view class="avatar-wrapper">
<image class="avatar" src="/static/images/yisheng.png" mode="aspectFill"></image>
</view>
<view class="message-content">
<view class="message-bubble typing-bubble">
<view class="typing-indicator">
<view class="dot"></view>
<view class="dot"></view>
<view class="dot"></view>
</view>
</view>
</view>
</view>
<!-- 底部占位 -->
<view class="bottom-placeholder"></view>
</view>
</scroll-view>
<!-- 底部输入区域 -->
<view class="input-section" :class="{ 'safe-bottom': safeBottom }">
<view class="input-wrapper">
<!-- 文本输入框 -->
<view class="text-input-wrapper">
<input
class="text-input"
type="text"
v-model="inputMessage"
placeholder="请输入您的问题..."
placeholder-class="input-placeholder"
:confirm-type="'send'"
@confirm="sendMessage"
@focus="handleInputFocus"
/>
</view>
<!-- 表情按钮 -->
<view class="action-btn" @tap="showEmoji">
<image class="action-icon" src="/static/images/xiaolian.png" mode="aspectFit"></image>
</view>
<!-- 图片按钮 -->
<view class="action-btn" @tap="chooseImage">
<image class="action-icon" src="/static/images/tupian.png" mode="aspectFit"></image>
</view>
<!-- 发送按钮 -->
<view v-if="inputMessage.trim()" class="send-btn" @tap="sendMessage">
<image class="send-icon" src="/static/images/send.png" mode="aspectFit"></image>
</view>
</view>
</view>
<!-- 表情面板 -->
<view class="emoji-panel" v-if="showEmojiPanel">
<view class="emoji-list">
<view class="emoji-item" v-for="(emoji, index) in emojiList" :key="index" @tap="selectEmoji(emoji)">
<text>{{ emoji }}</text>
</view>
</view>
<view class="emoji-close" @tap="showEmojiPanel = false">完成</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { onLoad } from '@dcloudio/uni-app'
import doctApi from '@/https/api/doct/index.js'
import { deepSeekChatStream } from '@/https/api/ai/deepseek.js'
const currentTimeText = ref('')
const expireSeconds = ref(0)
const expireText = ref('00:00')
let currentTimeTimer = null
let expireTimer = null
const formatTime = (date = new Date()) => {
const h = String(date.getHours()).padStart(2, '0')
const m = String(date.getMinutes()).padStart(2, '0')
return `${h}:${m}`
}
const formatExpire = (seconds = 0) => {
const safe = Math.max(0, Number(seconds) || 0)
const m = Math.floor(safe / 60)
const s = safe % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
const startCurrentTimeTick = () => {
currentTimeText.value = formatTime()
if (currentTimeTimer) clearInterval(currentTimeTimer)
currentTimeTimer = setInterval(() => {
currentTimeText.value = formatTime()
}, 1000)
}
const startExpireTick = (seconds = 0) => {
expireSeconds.value = Math.max(0, Number(seconds) || 0)
expireText.value = formatExpire(expireSeconds.value)
if (expireTimer) clearInterval(expireTimer)
if (expireSeconds.value <= 0) return
expireTimer = setInterval(() => {
if (expireSeconds.value > 0) {
expireSeconds.value -= 1
expireText.value = formatExpire(expireSeconds.value)
}
if (expireSeconds.value <= 0) {
clearInterval(expireTimer)
expireTimer = null
uni.showToast({
title: '问诊时效已结束',
icon: 'none'
})
}
}, 1000)
}
const userAvatar=ref()
// 问诊订单检测
onLoad(async () => {
// startCurrentTimeTick()
// try {
// uni.showLoading({
// title: '校验问诊资格...'
// })
// const res = await doctApi.checkAskOrder()
// uni.hideLoading()
// const hasValidOrder = !!res?.data?.has_ask_order
// const seconds = Number(res?.data?.expire_seconds || 0)
// startExpireTick(seconds)
// if (!hasValidOrder) {
// uni.showModal({
// title: '提示',
// content: '当前无有效问诊订单,请先创建问诊订单',
// showCancel: false,
// success: () => {
// uni.navigateBack()
// }
// })
// }
// } catch (error) {
// uni.hideLoading()
// console.error('检测问诊订单失败:', error)
// uni.showToast({
// title: '检测失败,请稍后重试',
// icon: 'none'
// })
// setTimeout(() => {
// uni.navigateBack()
// }, 1000)
// }
userAvatar.value=uni.getStorageSync('userInfo').avatar
console.log(userAvatar.value)
})
// 状态栏高度
const statusBarHeight = ref(0);
// 滚动到指定元素
const scrollIntoView = ref('');
// 是否开启底部安全区
const safeBottom = ref(true);
let msgSeq = 0
const makeMsg = (payload) => ({
id: `${Date.now()}-${msgSeq++}`,
...payload
})
// 消息列表
const messageList = ref([
{
id: `${Date.now()}-${msgSeq++}`,
type: 'ai',
content: '您好,请您描述下您的问题。'
}
]);
// 输入框内容
const inputMessage = ref('');
// AI正在输入
const aiTyping = ref(false);
// 显示表情面板
const showEmojiPanel = ref(false);
const sending = ref(false)
// 表情列表
const emojiList = ref([
'😊',
'😂',
'😍',
'🥰',
'😘',
'😗',
'😙',
'😚',
'😋',
'😛',
'😝',
'😜',
'🤪',
'🤨',
'🧐',
'🤓',
'😎',
'🥳',
'🤩',
'😏',
'😒',
'😞',
'😔',
'😟',
'😕',
'🙁',
'☹️',
'😣',
'😖',
'😫',
'😩',
'🥺',
'😢',
'😭',
'😤',
'😠',
'😡',
'🤬',
'🤯',
'😳',
'🥵',
'🥶',
'😱',
'😨',
'😰',
'😥',
'😓',
'🤗',
'🤔',
'🤭',
'🤫',
'🤥',
'😶',
'😐',
'😑',
'😬',
'🙄',
'😯',
'😦',
'😧',
'😮',
'😲',
'🥱',
'😴',
'🤤',
'😪',
'😵',
'🤐',
'🥴',
'🤢',
'🤮',
'🤧',
'😷',
'🤒',
'🤕'
]);
// 获取状态栏高度
onMounted(() => {
const systemInfo = uni.getSystemInfoSync();
statusBarHeight.value = systemInfo.statusBarHeight || 40;
// 滚动到底部
scrollToBottom();
});
onUnmounted(() => {
if (currentTimeTimer) {
clearInterval(currentTimeTimer)
currentTimeTimer = null
}
if (expireTimer) {
clearInterval(expireTimer)
expireTimer = null
}
})
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
const lastIndex = messageList.value.length - 1;
if (lastIndex >= 0) {
scrollIntoView.value = `msg-${lastIndex}`;
}
});
};
// 处理滚动(保留回调位,避免后续需要时再加)
const handleScroll = (_e) => {};
// 返回上一页
const goBack = () => {
uni.navigateBack();
};
// 显示更多菜单
const showMoreMenu = () => {
uni.showActionSheet({
itemList: ['清空对话', '问题反馈'],
success: (res) => {
if (res.tapIndex === 0) {
clearChat();
} else if (res.tapIndex === 1) {
feedback();
}
}
});
};
// 清空对话
const clearChat = () => {
uni.showModal({
title: '提示',
content: '确定清空所有对话记录吗?',
success: (res) => {
if (res.confirm) {
messageList.value = [makeMsg({ type: 'ai', content: '您好,我是AI助手,有什么可以帮您的吗?' })];
}
}
});
};
// 问题反馈
const feedback = () => {
uni.navigateTo({
url: '/pages/feedback/feedback'
});
};
// 发送消息
const sendMessage = async () => {
const content = inputMessage.value.trim();
if (!content) return;
if (sending.value) return;
// 添加用户消息
messageList.value.push(makeMsg({ type: 'user', content }));
inputMessage.value = '';
// 滚动到底部
scrollToBottom();
// 显示AI正在输入
aiTyping.value = true;
scrollToBottom();
await sendAiReply()
};
// 修改 buildDeepSeekMessages 方法
const buildDeepSeekMessages = () => {
const systemPrompt = '你是一名专业的畜牧兽医AI问诊助手。请用简洁的语言回答,先追问关键症状(如日龄、精神、食欲、体温、粪便等),再给出可能原因和建议。避免长篇大论。'
// 只保留最近8轮对话,减少token消耗
const MAX_HISTORY = 8
const history = messageList.value
.filter((m) => !!m?.content && (m.type === 'user' || m.type === 'ai'))
.slice(-MAX_HISTORY) // 限制历史长度
.map((m) => ({
role: m.type === 'user' ? 'user' : 'assistant',
content: String(m.content || '').substring(0, 500) // 限制单条消息长度
}))
// 如果历史消息太多,保留最近的,但确保至少有一轮对话
if (history.length > MAX_HISTORY) {
history.splice(0, history.length - MAX_HISTORY)
}
return [{ role: 'system', content: systemPrompt }, ...history]
}
const sendAiReply = async () => {
sending.value = true
// 先显示"正在输入"状态
aiTyping.value = true
scrollToBottom()
let currentAiMsgId = null
let fullContent = ''
let hasReceivedContent = false
try {
await deepSeekChatStream({
messages: buildDeepSeekMessages(),
temperature: 0.2,
maxTokens: 800, // 限制输出长度
onChunk: (chunk, currentFullContent) => {
// 首次接收到内容时,关闭输入动画并创建消息
if (!hasReceivedContent) {
aiTyping.value = false
hasReceivedContent = true
// 创建新的AI消息
const newMsg = makeMsg({
type: 'ai',
content: currentFullContent
})
messageList.value.push(newMsg)
currentAiMsgId = newMsg.id
} else {
// 更新现有消息内容
const aiMsg = messageList.value.find(m => m.id === currentAiMsgId)
if (aiMsg) {
aiMsg.content = currentFullContent
}
}
fullContent = currentFullContent
scrollToBottom()
},
onError: (error) => {
console.error('DeepSeek 流式请求失败:', error)
aiTyping.value = false
if (!hasReceivedContent) {
// 如果没有收到任何内容,显示错误消息
messageList.value.push(makeMsg({
type: 'ai',
content: '网络繁忙,请稍后重试。您可以重新描述问题,我会尽力帮您解答。'
}))
} else {
// 如果已经收到部分内容,在末尾追加错误提示
const aiMsg = messageList.value.find(m => m.id === currentAiMsgId)
if (aiMsg) {
aiMsg.content += '\n\n⚠️ 网络异常,回复可能不完整,请稍后重试。'
}
}
scrollToBottom()
},
onComplete: (finalContent) => {
console.log('流式响应完成', finalContent)
sending.value = false
// 确保最终内容完整
if (currentAiMsgId && finalContent) {
const aiMsg = messageList.value.find(m => m.id === currentAiMsgId)
if (aiMsg) {
aiMsg.content = finalContent
}
}
}
})
} catch (error) {
console.error('发送消息失败:', error)
aiTyping.value = false
messageList.value.push(makeMsg({
type: 'ai',
content: '服务异常,请稍后再试。'
}))
sending.value = false
scrollToBottom()
}
}
// 选择图片
const chooseImage = () => {
uni.chooseImage({
count: 1,
success: (res) => {
const tempFilePaths = res.tempFilePaths;
// 添加图片消息
messageList.value.push(makeMsg({ type: 'user', image: tempFilePaths[0] }));
// 滚动到底部
scrollToBottom();
// AI回复
aiTyping.value = true;
setTimeout(() => {
aiTyping.value = false;
messageList.value.push(makeMsg({ type: 'ai', content: '图片已收到,正在分析中,请稍候...' }));
scrollToBottom();
}, 1000);
}
});
};
// 预览图片
const previewImage = (url) => {
uni.previewImage({
urls: [url]
});
};
// 显示表情面板
const showEmoji = () => {
showEmojiPanel.value = !showEmojiPanel.value;
};
// 选择表情
const selectEmoji = (emoji) => {
inputMessage.value += emoji;
};
// 输入框获得焦点
const handleInputFocus = () => {
showEmojiPanel.value = false;
};
</script>
<style lang="scss" scoped>
.ai-consult-page {
background-color: #f5f5f5;
min-height: 100vh;
position: relative;
}
// 自定义导航栏
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: #ffffff;
z-index: 100;
padding-bottom: 20rpx;
.nav-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30rpx;
height: 88rpx;
.nav-left {
width: 48rpx;
height: 48rpx;
.back-icon {
width: 100%;
height: 100%;
}
}
.nav-title {
font-size: 36rpx;
font-weight: 600;
color: #27ae60;
}
.nav-right {
width: 48rpx;
height: 48rpx;
.more-icon {
width: 100%;
height: 100%;
}
}
}
}
// 聊天滚动区域
.chat-scroll {
background-color: #f5f5f5;
padding: 0 30rpx;
box-sizing: border-box;
height: calc(100vh - 150rpx);
/* 兜底隐藏滚动条(部分端不认 show-scrollbar) */
::-webkit-scrollbar {
width: 0;
height: 0;
color: transparent;
}
}
.chat-container {
padding: 30rpx 0;
}
// 时间标签
.time-badge {
text-align: center;
font-size: 24rpx;
color: #999999;
margin: 20rpx 0 10rpx;
}
.expire-badge {
text-align: center;
font-size: 26rpx;
color: #27ae60;
font-weight: 600;
margin-bottom: 16rpx;
}
// 消息项
.message-item {
display: flex;
margin-bottom: 30rpx;
&.user-message {
flex-direction: row-reverse;
}
}
// 头像
.avatar-wrapper {
width: 80rpx;
height: 80rpx;
flex-shrink: 0;
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #e0e0e0;
}
}
// 消息内容区域
.message-content {
max-width: 70%;
margin: 0 20rpx;
}
// 消息气泡
.message-bubble {
padding: 20rpx 24rpx;
background-color: #ffffff;
border-radius: 20rpx;
font-size: 28rpx;
color: #333333;
line-height: 1.5;
word-break: break-word;
&.typing-bubble {
padding: 20rpx 30rpx;
}
}
// 用户消息样式
.user-message .message-bubble {
background-color: #27ae60;
color: #ffffff;
}
// 图片消息
.message-image {
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
overflow: hidden;
.chat-image {
width: 100%;
height: 100%;
}
}
// 用户图片消息
.user-message .message-image {
margin-left: auto;
}
// 正在输入动画
.typing-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
.dot {
width: 12rpx;
height: 12rpx;
background-color: #999999;
border-radius: 50%;
animation: typing 1.4s infinite ease-in-out;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-10px);
}
}
// 底部输入区域
.input-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #ffffff;
padding: 20rpx 30rpx;
border-top: 1rpx solid #f0f0f0;
&.safe-bottom {
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
}
.input-wrapper {
display: flex;
align-items: center;
gap: 20rpx;
}
// 操作按钮
.action-btn {
width: 48rpx;
height: 48rpx;
flex-shrink: 0;
.action-icon {
width: 100%;
height: 100%;
}
}
// 文本输入框
.text-input-wrapper {
flex: 1;
height: 72rpx;
background-color: #f5f5f5;
border-radius: 36rpx;
padding: 0 30rpx;
.text-input {
width: 100%;
height: 100%;
font-size: 28rpx;
}
.input-placeholder {
color: #999999;
font-size: 28rpx;
}
}
// 发送按钮
.send-btn {
width: 48rpx;
height: 48rpx;
flex-shrink: 0;
.send-icon {
width: 100%;
height: 100%;
}
}
// 表情面板
.emoji-panel {
position: fixed;
bottom: 120rpx;
left: 0;
right: 0;
background-color: #ffffff;
border-top: 1rpx solid #f0f0f0;
padding: 20rpx;
z-index: 101;
.emoji-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
max-height: 400rpx;
overflow-y: auto;
padding: 10rpx;
.emoji-item {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
background-color: #f5f5f5;
border-radius: 10rpx;
&:active {
background-color: #e0e0e0;
}
}
}
.emoji-close {
text-align: center;
padding: 20rpx;
color: #27ae60;
font-size: 28rpx;
border-top: 1rpx solid #f0f0f0;
margin-top: 10rpx;
}
}
// 底部占位
.bottom-placeholder {
height: 20rpx;
}
</style>
三、核心功能解析
1. 消息交互流程
- 用户输入问题并发送 → 添加到消息列表
- 显示 AI 正在输入状态 → 调用 DeepSeek API
- 接收 AI 回复 → 添加到消息列表 → 滚动到底部
- 异常处理:网络错误 / API 报错 → 友好提示
2. 关键技术点
(1)DeepSeek 消息格式构建
const buildDeepSeekMessages = () => {
const systemPrompt = '你是一名专业的畜牧兽医AI问诊助手...'
const history = messageList.value
.filter((m) => !!m?.content && (m.type === 'user' || m.type === 'ai'))
.map((m) => ({
role: m.type === 'user' ? 'user' : 'assistant',
content: String(m.content || '')
}))
return [{ role: 'system', content: systemPrompt }, ...history]
}
system:系统提示词,定义 AI 角色和回复规则user:用户消息assistant:AI 回复
(2)倒计时功能
- 实时显示当前时间
- 问诊时长倒计时,到期自动提示
- 页面销毁时清除定时器,避免内存泄漏
(3)UI 交互优化
- AI 正在输入动画效果
- 消息气泡区分用户 / AI 样式
- 适配底部安全区(iPhone 刘海屏)
- 表情面板选择与插入
- 图片选择与预览
四、总结
本文基于 UniApp+Vue3 实现了完整的 DeepSeek AI 问诊聊天功能,核心包括:
- DeepSeek API 封装与调用
- 聊天界面 UI 实现与交互
- 消息管理与状态控制
- 异常处理与用户体验优化
该方案可快速适配各类 AI 聊天场景,只需修改系统提示词即可切换不同的 AI 角色(如客服、助手、教育等)。生产环境中需重点关注 API Key 的安全管理和性能优化,提升用户体验。
更多推荐

所有评论(0)