uni-app 客服系统

核心知识点

客服系统架构

客服系统是应用中重要的用户支持渠道,它允许用户与客服人员进行实时沟通。在 uni-app 中实现客服系统需要考虑以下几个方面:

  1. 数据结构设计

    • 消息数据模型
    • 会话数据模型
    • 客服人员数据模型
  2. 核心功能模块

    • 消息收发
    • 客服分配
    • 聊天记录管理
    • 客服状态管理
    • 跨端数据同步
  3. 性能优化

    • 消息缓存策略
    • 聊天记录加载优化
    • 网络请求优化

消息系统设计

消息系统是客服系统的核心,需要考虑以下因素:

  1. 消息类型

    • 文本消息
    • 图片消息
    • 语音消息
    • 视频消息
    • 文件消息
    • 系统消息
  2. 消息状态

    • 发送中
    • 已发送
    • 已读
    • 发送失败
  3. 消息存储

    • 本地存储
    • 服务器存储
    • 消息同步机制

客服分配策略

客服分配是客服系统中的重要环节,常见的分配策略包括:

  1. 轮询分配

    • 按顺序分配客服
    • 简单易实现
  2. 负载均衡

    • 根据客服当前会话数分配
    • 确保客服 workload 均衡
  3. 技能匹配

    • 根据客服技能和用户问题类型匹配
    • 提高解决问题的效率
  4. 优先级分配

    • 根据用户等级或问题紧急程度分配
    • 确保重要用户得到及时响应

实用案例:实现在线客服功能

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 中实现客服系统的方法,包括:

  1. 核心知识点:客服系统架构、消息系统设计、客服分配策略
  2. 实现方法
    • 客服 API 服务开发
    • 客服聊天组件实现
    • 客服列表组件实现
    • 客服管理后台实现
  3. 实用案例:完整的在线客服功能实现
  4. 性能优化:消息缓存、聊天记录加载优化、网络请求优化

通过本教程的学习,你应该能够掌握在 uni-app 中实现客服系统的方法,并能够根据实际项目需求进行定制和扩展。客服系统是应用中重要的用户支持渠道,合理的设计和实现可以显著提升用户满意度和品牌形象。

在实际开发中,还需要考虑更多因素,如客服绩效考核、智能客服集成、多语言支持等,这些都需要根据具体的业务需求进行调整和优化。

« 上一篇 uni-app 物流系统 下一篇 » uni-app 活动系统