uni-app 客服系统
核心知识点
客服系统架构
客服系统是应用中重要的用户支持渠道,它允许用户与客服人员进行实时沟通。在 uni-app 中实现客服系统需要考虑以下几个方面:
数据结构设计:
- 消息数据模型
- 会话数据模型
- 客服人员数据模型
核心功能模块:
- 消息收发
- 客服分配
- 聊天记录管理
- 客服状态管理
- 跨端数据同步
性能优化:
- 消息缓存策略
- 聊天记录加载优化
- 网络请求优化
消息系统设计
消息系统是客服系统的核心,需要考虑以下因素:
消息类型:
- 文本消息
- 图片消息
- 语音消息
- 视频消息
- 文件消息
- 系统消息
消息状态:
- 发送中
- 已发送
- 已读
- 发送失败
消息存储:
- 本地存储
- 服务器存储
- 消息同步机制
客服分配策略
客服分配是客服系统中的重要环节,常见的分配策略包括:
轮询分配:
- 按顺序分配客服
- 简单易实现
负载均衡:
- 根据客服当前会话数分配
- 确保客服 workload 均衡
技能匹配:
- 根据客服技能和用户问题类型匹配
- 提高解决问题的效率
优先级分配:
- 根据用户等级或问题紧急程度分配
- 确保重要用户得到及时响应
实用案例:实现在线客服功能
1. 客服系统数据模型设计
首先,我们需要设计客服系统相关的数据模型,包括消息、会话、客服人员等。
// 消息数据模型
const messageModel = {
id: String, // 消息ID
sessionId: String, // 会话ID
senderId: String, // 发送者ID
senderType: String, // 发送者类型(user/customer_service)
type: String, // 消息类型(text/image/voice/video/file/system)
content: String, // 消息内容
status: String, // 消息状态(sending/sent/read/failed)
createTime: String, // 创建时间
readTime: String // 已读时间
}
// 会话数据模型
const sessionModel = {
id: String, // 会话ID
userId: String, // 用户ID
customerServiceId: String, // 客服ID
customerServiceName: String, // 客服名称
lastMessage: Object, // 最后一条消息
unreadCount: Number, // 未读消息数
status: String, // 会话状态(active/closed)
createTime: String, // 创建时间
updateTime: String // 更新时间
}
// 客服人员数据模型
const customerServiceModel = {
id: String, // 客服ID
name: String, // 客服名称
avatar: String, // 客服头像
status: String, // 在线状态(online/offline/busy)
skillTags: Array, // 技能标签
currentSessions: Number, // 当前会话数
maxSessions: Number, // 最大会话数
loginTime: String // 登录时间
}2. 客服 API 服务实现
创建客服 API 服务,用于处理消息收发、会话管理等功能:
// services/customerService.js
/**
* 获取用户会话列表
* @returns {Promise<Array>} 会话列表
*/
export const getSessionList = async () => {
try {
const res = await uni.request({
url: 'https://api.example.com/customer-service/sessions',
method: 'GET',
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + uni.getStorageSync('token')
}
})
return res.data.sessions || []
} catch (error) {
console.error('获取会话列表失败:', error)
return []
}
}
/**
* 获取会话消息
* @param {String} sessionId 会话ID
* @param {Number} page 页码
* @param {Number} pageSize 每页大小
* @returns {Promise<Array>} 消息列表
*/
export const getSessionMessages = async (sessionId, page = 1, pageSize = 20) => {
try {
const res = await uni.request({
url: `https://api.example.com/customer-service/sessions/${sessionId}/messages`,
method: 'GET',
data: {
page,
pageSize
},
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + uni.getStorageSync('token')
}
})
return res.data.messages || []
} catch (error) {
console.error('获取会话消息失败:', error)
return []
}
}
/**
* 发送消息
* @param {String} sessionId 会话ID
* @param {Object} message 消息内容
* @returns {Promise<Object>} 发送结果
*/
export const sendMessage = async (sessionId, message) => {
try {
const res = await uni.request({
url: `https://api.example.com/customer-service/sessions/${sessionId}/messages`,
method: 'POST',
data: message,
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + uni.getStorageSync('token')
}
})
return res.data
} catch (error) {
console.error('发送消息失败:', error)
throw error
}
}
/**
* 创建会话
* @returns {Promise<Object>} 会话信息
*/
export const createSession = async () => {
try {
const res = await uni.request({
url: 'https://api.example.com/customer-service/sessions',
method: 'POST',
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + uni.getStorageSync('token')
}
})
return res.data
} catch (error) {
console.error('创建会话失败:', error)
throw error
}
}
/**
* 关闭会话
* @param {String} sessionId 会话ID
* @returns {Promise<Object>} 关闭结果
*/
export const closeSession = async (sessionId) => {
try {
const res = await uni.request({
url: `https://api.example.com/customer-service/sessions/${sessionId}/close`,
method: 'POST',
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + uni.getStorageSync('token')
}
})
return res.data
} catch (error) {
console.error('关闭会话失败:', error)
throw error
}
}
/**
* 标记消息已读
* @param {String} sessionId 会话ID
* @param {String} messageId 消息ID
* @returns {Promise<Object>} 标记结果
*/
export const markMessageAsRead = async (sessionId, messageId) => {
try {
const res = await uni.request({
url: `https://api.example.com/customer-service/sessions/${sessionId}/messages/${messageId}/read`,
method: 'POST',
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + uni.getStorageSync('token')
}
})
return res.data
} catch (error) {
console.error('标记消息已读失败:', error)
throw error
}
}
/**
* 订阅消息推送
* @param {String} sessionId 会话ID
* @param {Function} callback 回调函数
* @returns {Function} 取消订阅函数
*/
export const subscribeMessagePush = (sessionId, callback) => {
// 这里可以实现 WebSocket 订阅
const socketTask = uni.connectSocket({
url: `wss://api.example.com/customer-service/ws?sessionId=${sessionId}&token=${uni.getStorageSync('token')}`,
success: () => {
console.log('WebSocket 连接成功')
},
fail: (error) => {
console.error('WebSocket 连接失败:', error)
}
})
socketTask.onOpen(() => {
console.log('WebSocket 连接已打开')
})
socketTask.onMessage((res) => {
try {
const message = JSON.parse(res.data)
callback(message)
} catch (error) {
console.error('解析消息失败:', error)
}
})
socketTask.onClose(() => {
console.log('WebSocket 连接已关闭')
})
socketTask.onError((error) => {
console.error('WebSocket 错误:', error)
})
// 返回取消订阅函数
return () => {
socketTask.close()
}
}2. 客服聊天组件实现
创建客服聊天页面组件,实现消息收发和聊天界面:
<template>
<view class="chat-container">
<!-- 聊天头部 -->
<view class="chat-header">
<view class="header-left" @click="goBack">
<text class="back-icon">←</text>
</view>
<view class="header-center">
<text class="service-name">{{ sessionInfo.customerServiceName || '在线客服' }}</text>
<text class="service-status" :class="{ 'online': serviceOnline }">
{{ serviceOnline ? '在线' : '离线' }}
</text>
</view>
<view class="header-right" @click="showMenu">
<text class="menu-icon">⋮</text>
</view>
</view>
<!-- 聊天内容 -->
<view class="chat-content" ref="chatContent">
<!-- 系统消息 -->
<view v-if="showWelcomeMessage" class="system-message">
<text>欢迎咨询,请问有什么可以帮助您的?</text>
</view>
<!-- 消息列表 -->
<view v-for="(message, index) in messages" :key="message.id"
class="message-item"
:class="{
'user-message': message.senderType === 'user',
'service-message': message.senderType === 'customer_service'
}">
<!-- 用户消息 -->
<view v-if="message.senderType === 'user'" class="message-wrapper">
<view class="message-content">
<view class="message-bubble" :class="{ 'failed': message.status === 'failed' }">
<text v-if="message.type === 'text'">{{ message.content }}</text>
<image v-else-if="message.type === 'image'" :src="message.content" mode="aspectFit"></image>
<view v-else-if="message.type === 'voice'" class="voice-message">
<text>🎵 语音消息</text>
<text class="voice-duration">{{ message.duration }}''</text>
</view>
<view v-else-if="message.type === 'file'" class="file-message">
<text>📎 {{ message.fileName }}</text>
</view>
</view>
<view class="message-status">
<text v-if="message.status === 'sending'">发送中...</text>
<text v-else-if="message.status === 'sent'">已发送</text>
<text v-else-if="message.status === 'read'">已读</text>
<text v-else-if="message.status === 'failed'" class="status-failed">发送失败</text>
</view>
</view>
<view class="message-avatar">
<image src="/static/user-avatar.png" mode="aspectFill"></image>
</view>
</view>
<!-- 客服消息 -->
<view v-else class="message-wrapper">
<view class="message-avatar">
<image :src="sessionInfo.customerServiceAvatar || '/static/service-avatar.png'" mode="aspectFill"></image>
</view>
<view class="message-content">
<view class="message-bubble">
<text v-if="message.type === 'text'">{{ message.content }}</text>
<image v-else-if="message.type === 'image'" :src="message.content" mode="aspectFit"></image>
<view v-else-if="message.type === 'voice'" class="voice-message">
<text>🎵 语音消息</text>
<text class="voice-duration">{{ message.duration }}''</text>
</view>
<view v-else-if="message.type === 'file'" class="file-message">
<text>📎 {{ message.fileName }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadingMore" class="loading-more">
<text>加载中...</text>
</view>
</view>
<!-- 输入区域 -->
<view class="chat-input">
<view class="input-wrapper">
<text class="emoji-icon" @click="toggleEmoji">😊</text>
<textarea
v-model="inputContent"
placeholder="请输入消息..."
class="message-input"
@focus="onInputFocus"
@blur="onInputBlur"
></textarea>
<text class="more-icon" @click="toggleMore">+</text>
</view>
<view class="send-wrapper">
<button class="send-button" @click="sendMessage" :disabled="!canSend">
发送
</button>
</view>
</view>
<!-- 更多功能面板 -->
<view v-if="showMorePanel" class="more-panel">
<view class="more-item" @click="selectImage">
<image src="/static/icon-image.png" mode="aspectFit"></image>
<text>图片</text>
</view>
<view class="more-item" @click="recordVoice">
<image src="/static/icon-voice.png" mode="aspectFit"></image>
<text>语音</text>
</view>
<view class="more-item" @click="selectFile">
<image src="/static/icon-file.png" mode="aspectFit"></image>
<text>文件</text>
</view>
<view class="more-item" @click="takePhoto">
<image src="/static/icon-camera.png" mode="aspectFit"></image>
<text>拍照</text>
</view>
</view>
<!-- 菜单面板 -->
<view v-if="showMenuPanel" class="menu-panel">
<view class="menu-item" @click="viewHistory">
<text>查看历史记录</text>
</view>
<view class="menu-item" @click="switchService">
<text>切换客服</text>
</view>
<view class="menu-item" @click="closeSession">
<text class="text-danger">结束会话</text>
</view>
</view>
</view>
</template>
<script>
import { getSessionMessages, sendMessage, createSession, closeSession, markMessageAsRead, subscribeMessagePush } from '@/services/customerService'
export default {
data() {
return {
sessionId: '',
sessionInfo: {
customerServiceName: '',
customerServiceAvatar: ''
},
messages: [],
inputContent: '',
showWelcomeMessage: true,
serviceOnline: true,
loadingMore: false,
showMorePanel: false,
showMenuPanel: false,
page: 1,
pageSize: 20,
hasMore: true,
unsubscribe: null
}
},
computed: {
canSend() {
return this.inputContent.trim().length > 0
}
},
onLoad(options) {
this.sessionId = options.sessionId
if (this.sessionId) {
this.loadSessionInfo()
this.loadMessages()
} else {
this.createNewSession()
}
},
onUnload() {
// 取消订阅
if (this.unsubscribe) {
this.unsubscribe()
}
},
methods: {
// 返回
goBack() {
uni.navigateBack()
},
// 显示菜单
showMenu() {
this.showMenuPanel = !this.showMenuPanel
this.showMorePanel = false
},
// 切换更多面板
toggleMore() {
this.showMorePanel = !this.showMorePanel
this.showMenuPanel = false
},
// 切换表情面板
toggleEmoji() {
// 实现表情选择功能
},
// 输入框聚焦
onInputFocus() {
this.showMorePanel = false
this.showMenuPanel = false
},
// 输入框失焦
onInputBlur() {
// 处理输入框失焦逻辑
},
// 加载会话信息
loadSessionInfo() {
// 从本地存储或服务器加载会话信息
const sessionInfo = uni.getStorageSync(`session_${this.sessionId}`)
if (sessionInfo) {
this.sessionInfo = sessionInfo
}
},
// 创建新会话
async createNewSession() {
try {
uni.showLoading({ title: '连接中...' })
const session = await createSession()
this.sessionId = session.id
this.sessionInfo = {
customerServiceName: session.customerServiceName,
customerServiceAvatar: session.customerServiceAvatar
}
// 保存会话信息到本地
uni.setStorageSync(`session_${this.sessionId}`, this.sessionInfo)
// 加载消息
this.loadMessages()
} catch (error) {
console.error('创建会话失败:', error)
uni.showToast({ title: '连接客服失败', icon: 'none' })
} finally {
uni.hideLoading()
}
},
// 加载消息
async loadMessages() {
try {
if (!this.hasMore) return
this.loadingMore = true
const newMessages = await getSessionMessages(this.sessionId, this.page, this.pageSize)
if (newMessages.length < this.pageSize) {
this.hasMore = false
}
// 反转消息顺序,使最新消息在底部
if (this.page === 1) {
this.messages = newMessages.reverse()
} else {
this.messages = [...newMessages.reverse(), ...this.messages]
}
this.page++
// 滚动到底部
this.scrollToBottom()
// 订阅消息推送
this.unsubscribe = subscribeMessagePush(this.sessionId, (message) => {
this.messages.push(message)
this.scrollToBottom()
// 标记消息已读
this.markAsRead(message.id)
})
} catch (error) {
console.error('加载消息失败:', error)
} finally {
this.loadingMore = false
}
},
// 发送消息
async sendMessage() {
if (!this.canSend) return
const content = this.inputContent.trim()
this.inputContent = ''
// 创建临时消息
const tempMessage = {
id: `temp_${Date.now()}`,
sessionId: this.sessionId,
senderId: uni.getStorageSync('userId'),
senderType: 'user',
type: 'text',
content: content,
status: 'sending',
createTime: new Date().toISOString()
}
// 添加到消息列表
this.messages.push(tempMessage)
this.scrollToBottom()
try {
// 发送消息
const result = await sendMessage(this.sessionId, {
type: 'text',
content: content
})
// 更新消息状态
const index = this.messages.findIndex(msg => msg.id === tempMessage.id)
if (index !== -1) {
this.messages[index] = {
...result.message,
status: 'sent'
}
}
} catch (error) {
console.error('发送消息失败:', error)
// 更新消息状态为失败
const index = this.messages.findIndex(msg => msg.id === tempMessage.id)
if (index !== -1) {
this.messages[index].status = 'failed'
}
uni.showToast({ title: '发送失败,请重试', icon: 'none' })
}
},
// 标记消息已读
async markAsRead(messageId) {
try {
await markMessageAsRead(this.sessionId, messageId)
} catch (error) {
console.error('标记消息已读失败:', error)
}
},
// 选择图片
selectImage() {
uni.chooseImage({
count: 9,
sizeType: ['original', 'compressed'],
sourceType: ['album'],
success: (res) => {
this.uploadFile(res.tempFilePaths[0], 'image')
}
})
},
// 拍照
takePhoto() {
uni.chooseImage({
count: 1,
sizeType: ['original', 'compressed'],
sourceType: ['camera'],
success: (res) => {
this.uploadFile(res.tempFilePaths[0], 'image')
}
})
},
// 录音
recordVoice() {
uni.showModal({
title: '录音',
content: '点击开始录音',
success: (res) => {
if (res.confirm) {
// 实现录音功能
uni.showToast({ title: '录音功能开发中', icon: 'none' })
}
}
})
},
// 选择文件
selectFile() {
uni.chooseFile({
count: 1,
success: (res) => {
this.uploadFile(res.tempFilePaths[0], 'file')
}
})
},
// 上传文件
uploadFile(tempFilePath, type) {
uni.uploadFile({
url: 'https://api.example.com/upload',
filePath: tempFilePath,
name: 'file',
formData: {
type: type
},
header: {
'Authorization': 'Bearer ' + uni.getStorageSync('token')
},
success: (uploadRes) => {
try {
const result = JSON.parse(uploadRes.data)
if (result.success) {
// 发送文件消息
this.sendFileMessage(type, result.url, result.fileName)
} else {
uni.showToast({ title: '上传失败', icon: 'none' })
}
} catch (error) {
console.error('解析上传结果失败:', error)
uni.showToast({ title: '上传失败', icon: 'none' })
}
},
fail: (error) => {
console.error('上传文件失败:', error)
uni.showToast({ title: '上传失败', icon: 'none' })
}
})
},
// 发送文件消息
sendFileMessage(type, url, fileName) {
// 创建临时消息
const tempMessage = {
id: `temp_${Date.now()}`,
sessionId: this.sessionId,
senderId: uni.getStorageSync('userId'),
senderType: 'user',
type: type,
content: url,
fileName: fileName,
status: 'sending',
createTime: new Date().toISOString()
}
// 添加到消息列表
this.messages.push(tempMessage)
this.scrollToBottom()
// 发送消息
sendMessage(this.sessionId, {
type: type,
content: url,
fileName: fileName
}).then(result => {
// 更新消息状态
const index = this.messages.findIndex(msg => msg.id === tempMessage.id)
if (index !== -1) {
this.messages[index] = {
...result.message,
status: 'sent',
fileName: fileName
}
}
}).catch(error => {
console.error('发送文件消息失败:', error)
// 更新消息状态为失败
const index = this.messages.findIndex(msg => msg.id === tempMessage.id)
if (index !== -1) {
this.messages[index].status = 'failed'
}
uni.showToast({ title: '发送失败,请重试', icon: 'none' })
})
},
// 滚动到底部
scrollToBottom() {
setTimeout(() => {
const chatContent = this.$refs.chatContent
if (chatContent) {
chatContent.scrollTop = chatContent.scrollHeight
}
}, 100)
},
// 查看历史记录
viewHistory() {
uni.navigateTo({
url: `/pages/customer-service/history?sessionId=${this.sessionId}`
})
this.showMenuPanel = false
},
// 切换客服
switchService() {
uni.showToast({ title: '切换客服功能开发中', icon: 'none' })
this.showMenuPanel = false
},
// 结束会话
closeSession() {
uni.showModal({
title: '结束会话',
content: '确定要结束当前会话吗?',
success: (res) => {
if (res.confirm) {
closeSession(this.sessionId).then(() => {
uni.showToast({ title: '会话已结束', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1000)
}).catch(error => {
console.error('结束会话失败:', error)
uni.showToast({ title: '结束会话失败', icon: 'none' })
})
}
}
})
this.showMenuPanel = false
}
}
}
</script>
<style scoped>
.chat-container {
flex: 1;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
}
.chat-header {
height: 80rpx;
background-color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20rpx;
border-bottom: 1rpx solid #eee;
}
.header-left {
width: 60rpx;
}
.back-icon {
font-size: 40rpx;
color: #333;
}
.header-center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.service-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.service-status {
font-size: 20rpx;
color: #999;
margin-top: 4rpx;
}
.service-status.online {
color: #07c160;
}
.header-right {
width: 60rpx;
display: flex;
justify-content: flex-end;
}
.menu-icon {
font-size: 32rpx;
color: #333;
}
.chat-content {
flex: 1;
padding: 20rpx;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.system-message {
align-self: center;
background-color: rgba(0, 0, 0, 0.05);
padding: 8rpx 16rpx;
border-radius: 16rpx;
margin: 10rpx 0;
}
.system-message text {
font-size: 20rpx;
color: #666;
}
.message-item {
margin: 10rpx 0;
display: flex;
align-items: flex-end;
}
.user-message {
flex-direction: row-reverse;
}
.service-message {
flex-direction: row;
}
.message-wrapper {
display: flex;
align-items: flex-end;
max-width: 70%;
}
.user-message .message-wrapper {
flex-direction: row-reverse;
}
.message-avatar {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
overflow: hidden;
margin: 0 10rpx;
}
.message-avatar image {
width: 100%;
height: 100%;
}
.message-content {
display: flex;
flex-direction: column;
}
.message-bubble {
padding: 12rpx 16rpx;
border-radius: 16rpx;
max-width: 100%;
word-break: break-word;
}
.user-message .message-bubble {
background-color: #007aff;
color: #fff;
border-bottom-right-radius: 4rpx;
}
.service-message .message-bubble {
background-color: #fff;
color: #333;
border-bottom-left-radius: 4rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.1);
}
.message-bubble.failed {
opacity: 0.7;
}
.message-bubble image {
max-width: 100%;
max-height: 300rpx;
border-radius: 8rpx;
}
.voice-message {
display: flex;
align-items: center;
}
.voice-duration {
margin-left: 10rpx;
font-size: 20rpx;
}
.file-message {
padding: 10rpx;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 8rpx;
}
.message-status {
margin-top: 4rpx;
font-size: 18rpx;
color: #999;
align-self: flex-end;
}
.status-failed {
color: #ff4d4f;
}
.loading-more {
align-self: center;
padding: 10rpx 0;
font-size: 20rpx;
color: #999;
}
.chat-input {
background-color: #fff;
border-top: 1rpx solid #eee;
padding: 10rpx 20rpx;
display: flex;
align-items: flex-end;
}
.input-wrapper {
flex: 1;
display: flex;
align-items: flex-end;
background-color: #f5f5f5;
border-radius: 24rpx;
padding: 0 16rpx;
margin-right: 10rpx;
}
.emoji-icon {
font-size: 32rpx;
margin-right: 10rpx;
align-self: center;
}
.message-input {
flex: 1;
min-height: 48rpx;
max-height: 150rpx;
padding: 10rpx 0;
font-size: 24rpx;
color: #333;
resize: none;
}
.more-icon {
font-size: 32rpx;
margin-left: 10rpx;
align-self: center;
}
.send-wrapper {
display: flex;
align-items: flex-end;
}
.send-button {
width: 100rpx;
height: 48rpx;
background-color: #007aff;
color: #fff;
border-radius: 24rpx;
font-size: 24rpx;
line-height: 48rpx;
text-align: center;
}
.send-button:disabled {
background-color: #ccc;
}
.more-panel {
position: fixed;
bottom: 120rpx;
left: 0;
right: 0;
background-color: #fff;
border-top: 1rpx solid #eee;
padding: 20rpx;
display: flex;
justify-content: space-around;
}
.more-item {
display: flex;
flex-direction: column;
align-items: center;
width: 20%;
}
.more-item image {
width: 60rpx;
height: 60rpx;
margin-bottom: 10rpx;
}
.more-item text {
font-size: 20rpx;
color: #333;
}
.menu-panel {
position: fixed;
top: 80rpx;
right: 0;
width: 200rpx;
background-color: #fff;
border-radius: 8rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
z-index: 999;
}
.menu-item {
padding: 20rpx;
border-bottom: 1rpx solid #eee;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item text {
font-size: 24rpx;
color: #333;
}
.text-danger {
color: #ff4d4f;
}
</style>3. 客服列表组件实现
创建客服列表页面组件,实现会话列表和客服选择:
<template>
<view class="service-list-container">
<!-- 页面头部 -->
<view class="list-header">
<text class="header-title">在线客服</text>
</view>
<!-- 会话列表 -->
<view class="session-list">
<view v-if="sessions.length === 0" class="empty-sessions">
<image src="/static/empty-chat.png" mode="aspectFit"></image>
<text>暂无会话记录</text>
<button class="start-chat" @click="startNewChat">开始咨询</button>
</view>
<view v-else v-for="session in sessions" :key="session.id"
class="session-item"
@click="enterChat(session.id)">
<view class="session-avatar">
<image :src="session.customerServiceAvatar || '/static/service-avatar.png'" mode="aspectFill"></image>
</view>
<view class="session-info">
<view class="session-header">
<text class="service-name">{{ session.customerServiceName }}</text>
<text class="session-time">{{ formatTime(session.updateTime) }}</text>
</view>
<view class="session-content">
<text class="last-message">{{ session.lastMessage?.content || '' }}</text>
<text v-if="session.unreadCount > 0" class="unread-count">{{ session.unreadCount }}</text>
</view>
</view>
</view>
</view>
<!-- 客服推荐 -->
<view class="recommended-services">
<text class="section-title">推荐客服</text>
<view class="service-grid">
<view v-for="service in recommendedServices" :key="service.id"
class="service-card"
@click="selectService(service.id)">
<view class="service-avatar">
<image :src="service.avatar || '/static/service-avatar.png'" mode="aspectFill"></image>
<view class="service-status" :class="{ 'online': service.status === 'online' }"></view>
</view>
<text class="service-name">{{ service.name }}</text>
<text class="service-skills">{{ service.skillTags.join(', ') }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import { getSessionList, createSession } from '@/services/customerService'
export default {
data() {
return {
sessions: [],
recommendedServices: [
{
id: '1',
name: '客服小王',
avatar: '/static/service1.png',
status: 'online',
skillTags: ['订单问题', '退款']
},
{
id: '2',
name: '客服小李',
avatar: '/static/service2.png',
status: 'online',
skillTags: ['商品咨询', '配送']
},
{
id: '3',
name: '客服小张',
avatar: '/static/service3.png',
status: 'offline',
skillTags: ['技术支持', '账号问题']
}
]
}
},
onLoad() {
this.loadSessions()
},
methods: {
// 加载会话列表
async loadSessions() {
try {
const sessions = await getSessionList()
this.sessions = sessions
} catch (error) {
console.error('加载会话列表失败:', error)
}
},
// 开始新会话
async startNewChat() {
try {
uni.showLoading({ title: '连接中...' })
const result = await createSession()
uni.navigateTo({
url: `/pages/customer-service/chat?sessionId=${result.session.id}`
})
} catch (error) {
console.error('创建会话失败:', error)
uni.showToast({ title: '连接失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
}
},
// 进入聊天
enterChat(sessionId) {
uni.navigateTo({
url: `/pages/customer-service/chat?sessionId=${sessionId}`
})
},
// 选择客服
selectService(serviceId) {
// 这里可以实现选择指定客服的逻辑
this.startNewChat()
},
// 格式化时间
formatTime(time) {
if (!time) return ''
const date = new Date(time)
const now = new Date()
const diff = now - date
if (diff < 60 * 1000) {
return '刚刚'
} else if (diff < 60 * 60 * 1000) {
return `${Math.floor(diff / (60 * 1000))}分钟前`
} else if (diff < 24 * 60 * 60 * 1000) {
return `${Math.floor(diff / (60 * 60 * 1000))}小时前`
} else if (diff < 7 * 24 * 60 * 60 * 1000) {
return `${Math.floor(diff / (24 * 60 * 60 * 1000))}天前`
} else {
return `${date.getMonth() + 1}-${date.getDate()}`
}
}
}
}
</script>
<style scoped>
.service-list-container {
flex: 1;
background-color: #f5f5f5;
}
.list-header {
height: 80rpx;
background-color: #fff;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1rpx solid #eee;
}
.header-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.session-list {
padding: 20rpx;
background-color: #fff;
margin-bottom: 20rpx;
}
.empty-sessions {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
}
.empty-sessions image {
width: 200rpx;
height: 200rpx;
margin-bottom: 40rpx;
}
.empty-sessions text {
font-size: 28rpx;
color: #999;
margin-bottom: 40rpx;
}
.start-chat {
padding: 15rpx 60rpx;
background-color: #007aff;
color: #fff;
border-radius: 40rpx;
font-size: 24rpx;
}
.session-item {
display: flex;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.session-item:last-child {
border-bottom: none;
}
.session-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
overflow: hidden;
margin-right: 20rpx;
}
.session-avatar image {
width: 100%;
height: 100%;
}
.session-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10rpx;
}
.service-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.session-time {
font-size: 20rpx;
color: #999;
}
.session-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.last-message {
font-size: 24rpx;
color: #666;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 10rpx;
}
.unread-count {
background-color: #ff4d4f;
color: #fff;
font-size: 20rpx;
padding: 2rpx 10rpx;
border-radius: 12rpx;
min-width: 24rpx;
text-align: center;
}
.recommended-services {
padding: 20rpx;
background-color: #fff;
}
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
display: block;
}
.service-grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.service-card {
width: 30%;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20rpx;
}
.service-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
overflow: hidden;
margin-bottom: 10rpx;
position: relative;
}
.service-avatar image {
width: 100%;
height: 100%;
}
.service-status {
position: absolute;
bottom: 0;
right: 0;
width: 24rpx;
height: 24rpx;
border-radius: 50%;
background-color: #999;
border: 2rpx solid #fff;
}
.service-status.online {
background-color: #07c160;
}
.service-card .service-name {
font-size: 24rpx;
margin-bottom: 5rpx;
}
.service-skills {
font-size: 20rpx;
color: #666;
text-align: center;
line-height: 28rpx;
}
</style>4. 客服管理后台实现
创建客服管理后台组件,实现客服状态管理和消息管理:
<template>
<view class="service-admin-container">
<!-- 页面头部 -->
<view class="admin-header">
<text class="header-title">客服管理</text>
<text class="header-status" :class="{ 'online': isOnline }" @click="toggleStatus">
{{ isOnline ? '在线' : '离线' }}
</text>
</view>
<!-- 会话列表 -->
<view class="admin-session-list">
<view v-for="session in sessions" :key="session.id"
class="admin-session-item"
:class="{ 'active': activeSessionId === session.id }"
@click="selectSession(session.id)">
<view class="session-avatar">
<image :src="session.userAvatar || '/static/user-avatar.png'" mode="aspectFill"></image>
</view>
<view class="session-info">
<view class="session-header">
<text class="user-name">{{ session.userName }}</text>
<text class="session-time">{{ formatTime(session.updateTime) }}</text>
</view>
<view class="session-content">
<text class="last-message">{{ session.lastMessage?.content || '' }}</text>
<text v-if="session.unreadCount > 0" class="unread-count">{{ session.unreadCount }}</text>
</view>
</view>
</view>
</view>
<!-- 聊天区域 -->
<view v-if="activeSessionId" class="admin-chat-area">
<!-- 聊天头部 -->
<view class="chat-header">
<text class="user-name">{{ activeSession?.userName || '用户' }}</text>
<view class="header-actions">
<text class="action-btn" @click="transferSession">转接</text>
<text class="action-btn" @click="closeSession">结束</text>
</view>
</view>
<!-- 聊天内容 -->
<view class="chat-content" ref="chatContent">
<view v-for="(message, index) in activeMessages" :key="message.id"
class="message-item"
:class="{
'user-message': message.senderType === 'user',
'service-message': message.senderType === 'customer_service'
}">
<!-- 用户消息 -->
<view v-if="message.senderType === 'user'" class="message-wrapper">
<view class="message-avatar">
<image :src="activeSession?.userAvatar || '/static/user-avatar.png'" mode="aspectFill"></image>
</view>
<view class="message-content">
<view class="message-bubble">
<text v-if="message.type === 'text'">{{ message.content }}</text>
<image v-else-if="message.type === 'image'" :src="message.content" mode="aspectFit"></image>
</view>
</view>
</view>
<!-- 客服消息 -->
<view v-else class="message-wrapper">
<view class="message-content">
<view class="message-bubble">
<text v-if="message.type === 'text'">{{ message.content }}</text>
<image v-else-if="message.type === 'image'" :src="message.content" mode="aspectFit"></image>
</view>
</view>
<view class="message-avatar">
<image src="/static/service-avatar.png" mode="aspectFill"></image>
</view>
</view>
</view>
</view>
<!-- 输入区域 -->
<view class="chat-input">
<textarea
v-model="adminInput"
placeholder="请输入消息..."
class="message-input"
></textarea>
<button class="send-button" @click="sendAdminMessage" :disabled="!canSend">
发送
</button>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
isOnline: true,
sessions: [
{
id: '1',
userName: '用户A',
userAvatar: '/static/user1.png',
lastMessage: {
content: '您好,请问什么时候发货?'
},
unreadCount: 2,
updateTime: new Date().toISOString()
},
{
id: '2',
userName: '用户B',
userAvatar: '/static/user2.png',
lastMessage: {
content: '商品质量有问题,想申请退款'
},
unreadCount: 0,
updateTime: new Date(Date.now() - 3600000).toISOString()
}
],
activeSessionId: '',
activeSession: null,
activeMessages: [
{
id: '1',
sessionId: '1',
senderType: 'user',
type: 'text',
content: '您好,请问什么时候发货?',
createTime: new Date(Date.now() - 7200000).toISOString()
},
{
id: '2',
sessionId: '1',
senderType: 'customer_service',
type: 'text',
content: '您好,我们会在48小时内发货,请耐心等待',
createTime: new Date(Date.now() - 7100000).toISOString()
},
{
id: '3',
sessionId: '1',
senderType: 'user',
type: 'text',
content: '好的,谢谢',
createTime: new Date(Date.now() - 7000000).toISOString()
}
],
adminInput: ''
}
},
computed: {
canSend() {
return this.adminInput.trim().length > 0
}
},
methods: {
// 切换在线状态
toggleStatus() {
this.isOnline = !this.isOnline
uni.showToast({
title: `已切换为${this.isOnline ? '在线' : '离线'}状态`,
icon: 'success'
})
},
// 选择会话
selectSession(sessionId) {
this.activeSessionId = sessionId
this.activeSession = this.sessions.find(s => s.id === sessionId)
// 清空未读消息
const sessionIndex = this.sessions.findIndex(s => s.id === sessionId)
if (sessionIndex !== -1) {
this.sessions[sessionIndex].unreadCount = 0
}
},
// 发送消息
sendAdminMessage() {
if (!this.canSend) return
const content = this.adminInput.trim()
this.adminInput = ''
// 创建消息
const newMessage = {
id: `admin_${Date.now()}`,
sessionId: this.activeSessionId,
senderType: 'customer_service',
type: 'text',
content: content,
createTime: new Date().toISOString()
}
// 添加到消息列表
this.activeMessages.push(newMessage)
// 更新会话最后消息
const sessionIndex = this.sessions.findIndex(s => s.id === this.activeSessionId)
if (sessionIndex !== -1) {
this.sessions[sessionIndex].lastMessage = {
content: content
}
this.sessions[sessionIndex].updateTime = new Date().toISOString()
}
},
// 转接会话
transferSession() {
uni.showModal({
title: '转接会话',
content: '请选择要转接的客服',
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '转接成功', icon: 'success' })
}
}
})
},
// 结束会话
closeSession() {
uni.showModal({
title: '结束会话',
content: '确定要结束当前会话吗?',
success: (res) => {
if (res.confirm) {
// 从会话列表中移除
this.sessions = this.sessions.filter(s => s.id !== this.activeSessionId)
this.activeSessionId = ''
this.activeSession = null
this.activeMessages = []
uni.showToast({ title: '会话已结束', icon: 'success' })
}
}
})
},
// 格式化时间
formatTime(time) {
if (!time) return ''
const date = new Date(time)
return `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
}
}
}
</script>
<style scoped>
.service-admin-container {
flex: 1;
background-color: #f5f5f5;
display: flex;
}
.admin-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 80rpx;
background-color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20rpx;
border-bottom: 1rpx solid #eee;
z-index: 100;
}
.header-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.header-status {
font-size: 24rpx;
padding: 8rpx 16rpx;
border-radius: 16rpx;
background-color: #f5f5f5;
}
.header-status.online {
background-color: #07c160;
color: #fff;
}
.admin-session-list {
width: 300rpx;
background-color: #fff;
border-right: 1rpx solid #eee;
margin-top: 80rpx;
overflow-y: auto;
}
.admin-session-item {
padding: 20rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.admin-session-item.active {
background-color: #f5f5f5;
}
.admin-session-item .session-avatar {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
overflow: hidden;
margin-right: 15rpx;
}
.admin-session-item .session-info {
flex: 1;
}
.admin-session-item .session-header {
margin-bottom: 8rpx;
}
.user-name {
font-size: 24rpx;
font-weight: bold;
color: #333;
}
.admin-session-item .last-message {
font-size: 20rpx;
color: #666;
}
.admin-chat-area {
flex: 1;
margin-top: 80rpx;
display: flex;
flex-direction: column;
background-color: #fff;
}
.chat-header {
height: 60rpx;
border-bottom: 1rpx solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20rpx;
}
.header-actions {
display: flex;
}
.action-btn {
font-size: 24rpx;
color: #007aff;
margin-left: 20rpx;
}
.chat-content {
flex: 1;
padding: 20rpx;
overflow-y: auto;
}
.chat-input {
border-top: 1rpx solid #eee;
padding: 10rpx 20rpx;
display: flex;
align-items: flex-end;
}
.message-input {
flex: 1;
min-height: 48rpx;
max-height: 150rpx;
padding: 10rpx;
background-color: #f5f5f5;
border-radius: 24rpx;
margin-right: 10rpx;
font-size: 24rpx;
}
.send-button {
width: 100rpx;
height: 48rpx;
background-color: #007aff;
color: #fff;
border-radius: 24rpx;
font-size: 24rpx;
line-height: 48rpx;
text-align: center;
}
.send-button:disabled {
background-color: #ccc;
}
</style>性能优化策略
1. 消息缓存策略
- 使用本地存储缓存最近的聊天记录
- 实现消息分页加载,避免一次性加载过多消息
- 合理设置缓存过期时间
2. 聊天记录加载优化
- 实现虚拟列表,只渲染可视区域内的消息
- 使用图片懒加载,减少初始加载时间
- 优化消息渲染性能,避免不必要的计算
3. 网络请求优化
- 使用 WebSocket 实现实时消息推送,减少轮询
- 实现消息批量发送和接收
- 合理设置请求超时和重试机制
4. 客服分配优化
- 实现客服状态实时更新
- 优化客服分配算法,提高响应速度
- 实现智能客服机器人,减轻客服压力
总结
本教程详细介绍了在 uni-app 中实现客服系统的方法,包括:
- 核心知识点:客服系统架构、消息系统设计、客服分配策略
- 实现方法:
- 客服 API 服务开发
- 客服聊天组件实现
- 客服列表组件实现
- 客服管理后台实现
- 实用案例:完整的在线客服功能实现
- 性能优化:消息缓存、聊天记录加载优化、网络请求优化
通过本教程的学习,你应该能够掌握在 uni-app 中实现客服系统的方法,并能够根据实际项目需求进行定制和扩展。客服系统是应用中重要的用户支持渠道,合理的设计和实现可以显著提升用户满意度和品牌形象。
在实际开发中,还需要考虑更多因素,如客服绩效考核、智能客服集成、多语言支持等,这些都需要根据具体的业务需求进行调整和优化。