Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

客服工作台是什么?它能做解决什么问题?系统设计很复杂吗?带着这些问题,我们一起揭开转转客服工作台的面纱。

  • 系统整体介绍

  • 客服 IM 与第三屏数据通信

  • 第三屏多页签

  • 客服会话缓存

  • 全埋点统计

  • 最后的思考

前言

随着我司业务的拓展,用户咨询或反馈问题的场景和诉求也越来越多,客服团队不断壮大。客服工作台是客服团队用来解答和处理用户问题的操作平台。客服团队分为一线和二线,其中一线客服(后面统称 ** 在线客服 **)主要接待用户通过客服入口的进线咨询,二线客服主要通过信息查询、电话外呼等方式处理工单流转进一步解决用户问题。本文提到的客服工作台特指为在线客服服务的系统。

一、系统整体介绍

在线客服包括两部分,图中的左侧两屏属于客服与用户的聊天区域(后面统称客服 IM),其中第一屏展示客服的部分重要服务指标(满意度、首解率和接待量)、客服基本信息(昵称 / 头像 / 状态 / 时长)、当前会话和历史会话列表,第二屏为具体某个会话的聊天内容,而图中的右侧为当前会话对应的所有重要信息(后面统称第三屏),包括用户信息、订单信息、工单信息、售后信息、知识库等。

1.1 客服 IM

客服 IM 采用 iframe 内嵌的独立系统,主要负责当前会话的实时通讯、历史会话的消息展示、与第三屏数据通信的能力,后面会介绍客服 IM 与第三屏数据通信的消息分类和实现。

1.2 第三屏

第三屏是在线客服工作台至关重要的部分,因为它承载了大部分协助客服解答用户问题的信息。在系统实现层面,第三屏有两个核心的问题要解决——多页签会话缓存。多页签满足客服打开不同页面,并完成信息查阅和操作的需要,会话缓存是客服在同时处理多个进线且需要来回切换会话时,缓存不同会话状态的技术。

同时为了分析系统设计对于客服费力度(评价使用某个产品、服务来解决问题的困难程度)的影响,需要对在线客服工作台做用户行为的采集和分析。

二、客服 IM 与第三屏数据通信

上面提到第三屏需要提供解决问题的相关信息,但这些信息需要和进线的用户相关,比如用户信息、用户进线时咨询的订单或商品、用户命中的售后单或工单等。那么就需要 iframe(客服 IM)和外层系统(工单系统)通过 postMessage 进行数据通信。

以工单系统收到消息为例,看下第三屏的代码设计。

第三屏部分将数据通信和视图进行了隔离,Message 为通信层,IframeImpage 为视图层。通信层对 iframe 的 postMessage 消息进行监听收发,并将这些消息存储在 model 中共享和管理数据。

// 在线客服页面的入口import React from 'react'import Message from './message'import IframeImPage from './IframePage'import type { IframeImPagePropsType } from './@types'// Message为通信层,IframeImpage为视图层const IframeIndex: React.FC<IframeImPagePropsType> = (props) => {  return (    <Message {...props}>      <IframeImPage location={props?.location} />    </Message>  )}export default IframeIndex
// Messageimport React, { useRef, useEffect, useState } from 'react'import { useModel } from 'umi'import { PostMsg } from '@/utils/PostMsg'import { useDebounceFn } from 'ahooks'import type { CovCoreInfoType } from '@/models/@types'import type {  IframeImPagePropsType,  PcimPostMsgContent} from './@types'const IframeImPage: React.FC<IframeImPagePropsType> = ({ children, location, history }) => {    const {      ...      setCovCoreInfo,      ...   } = useModel<'imWorkPlateform'>('imWorkPlateform')  // 监听postMessage消息  const chatWrap = useRef<PostMsg>(new PostMsg(handleOpenChatWrap, 'createWrap'))  // 避免客服连续快速切换--防抖200ms  const { run: handleOpenChatWrap } = useDebounceFn(handleOpenChatWrapFn, {    wait: 200  })  // 点击用户会话,打开对应的第三屏  const handleOpenChatWrapFn = async(content: PcimPostMsgContent): Promise<void> => {    const {      convId,      contactUid,      ...    } = content    ... ...    const targetUrl: string = `/home?uid=${contactUid}***`    // 在第三屏打开指定路由页面    handleAddChangeTab({      url: targetUrl,      covCoreInfoData: {        convId,        contactUid,        ...      }    })    // model保存会话信息    setCovCoreInfo({      convId,      contactUid,      ...    })  }  useEffect(() => {    ...    return () => {      // 注销postMessage监听      chatWrap.current?.destroyPostMsg && chatWrap.current?.destroyPostMsg()    }  }) return <div>{children ? children : null}</div>}

三、第三屏多页签

针对 umi 多页签的实现,我们采用的是 antd 的 Tabs 组件,而具体需要渲染哪些页面,则需要通过自定义路由实现。目前多页签实现的功能如下:

功能
关闭页签
刷新当前页
更新页面 title
页签数量最大数
支持动态参数
常驻页面配置

从下面代码实现中可以看到,我们仿照 umi 写了一份路由对象和解析渲染的方法,把即将打开的页面地址,与路由中的 path 进行匹配,拿到对应的 component,交由 TabPannel 进行组件渲染。也可以参考《umi4.0 多页签设计》

//自定义路由const baseConfig = [  {    title: '首页',    component: (params: UserInfoParamsType) => import('../components/home/index'),    path: '/home',    keepAlivePermanentTab: true  },  {    title: '推荐信息',    component: (params: UserInfoParamsType) => import('../components/NewHome/index'),    path: '/newhome',    keepAlivePermanentTab: true  },  ...]// 多页签实现RouterView.tsxconst RouterView: React.FC<RouteViewParamsType> = (props) => { return (    <div>      <Tabs>        {panels.map((curPathKey, idx) => {          return (           <Tabs.TabPane>             // View是动态获取的路由中的组件               <Bundle component={View} />            </Tabs.TabPane>          )        })}      </Tabs>    </div>}// Bundle.tsximport React from 'react'import { useRef, useEffect, useState } from 'react'import ErrorBoundary from './error-boundary'import type { BundleParamsType } from '../../@types'const Bundle: React.FC<BundleParamsType> = (props) => {  const [CompCache, setCompCache] = useState<any>(null)  const initCompCache = async() => {    if (typeof props.component === 'function' && props.component() instanceof Promise) {      const CurComp = await props.component()      setCompCache(        <ErrorBoundary type={2}>          <CurComp.default {...props} />        </ErrorBoundary>      )    } else {      const CurComp = props.component      setCompCache(        <ErrorBoundary type={2}>          <CurComp {...props} />        </ErrorBoundary>      )    }  }  useEffect(() => {    initCompCache()  }, [props.component])  return CompCache ? CompCache : null}export default Bundle

四、客服会话缓存

先解释下为什么需要缓存会话。根据客服的熟练程度,可能出现 1Vn 的服务场景,即 1 个客服同时与 n 个用户进行沟通会话,那么第三屏信息必然会频繁的切换。客服在与每个用户沟通中操作的页面交互状态,需要在切换中保持不变。否则,客服又得重新打开或查询重复的信息,极大增加了操作费力度。因此系统需要具备能将数据和交互(即会话数据)缓存下来的能力。

以下是会话缓存的示意图。

图中包括四部分:LRU 操作、即将更新的会话数据、第三屏的渲染区域、当前会话数据;

  • LRU 是我们常见的通过链表实现的最大数值为 n 的缓存算法;

  • 会话数据分为当前会话数据和即将更新的会话数据,包含以下四部分信息:

  • 包含一个基于会话 id 的缓存唯一值 cacheKey

  • 以及代表第三屏渲染的真实 dom 的 childern

  • 用于动态挂载第三屏真实 dom 的 $ref(也就是图中的最右侧 renderRef)

  • 用于挂载 的容器 conRef

  • 第三屏的渲染区域是一个固定的 dom,它会随着会话的切换,替换为对应 cacheKey 的 dom 容器。

现在简单说下切换会话的时序逻辑。

  • ①表示将第三屏渲染的 dom 挂载回到所属缓存的会话数据

  • ② 表示将当前会话重新更新到 LRU 中

  • ③ 表示新建或切换会话时,基于新的会话 id,创建或从 LRU 中获取会话数据

  • ④ 表示把即将更新的会话数据的挂载容器替换第三屏当前的容器,实现动态挂载真实 dom

  • ⑤ 表示将新会话数据更新到 LRU 中。

最终的动态挂载真实 dom,在 KeepAliveScope 组件中完成(见下面代码)。第三屏的 dom 数据是从 LRU 缓冲中获取,并通过 createPortal 渲染在 renderRef 对应的实际节点上。

// IframeImPage 第三屏代码import { KeepAliveScope, KeepAlive } from '@/components/KeepAlive'const IframeImPage: React.FC<IframeImPagePropsType> = (props) => {  return <KeepAliveScope>    <KeepAlive  cacheKey={covCoreInfo.childConvId} maxLimit={5}>        <RootRouter          url={covCoreInfo.fullUrl}          needReplace={needReplace}          keepAliveCacheKey={covCoreInfo.childConvId}         />      </KeepAlive>  </KeepAliveScope>}export default IframeImPage// KeepAliveScopeconst KeepAliveScope: React.FC<KeepAliveScopePropsType> = ({ dispatch, keepAlive, children }) => {  return   return (    <KeepAliveContext.Provider value={contextState}>      <>        {/* 子内容渲染 */}        {children}        {/* 渲染每个缓存空间的内容 */}        {Object.keys(keepAlive.cache).map((namespace: string) => {          // 渲染缓存内容          return keepAlive.cache[namespace].map((data: LRUDataType) => {            // 每个缓存内容渲染到独立的容器中(第三个参数为key)            return ReactDOM.createPortal(              <div data-cache-namespace={namespace} data-cache-key={data.key}>                {data.val.children}              </div>,              data.val.$ref,              `${namespace}_${data.key}`            )          })        })}      </>    </KeepAliveContext.Provider>  )}export default connect(({ keepAlive }: { keepAlive: KeepAliveStateType }) => ({  keepAlive}))(KeepAliveScope)

五、全埋点统计

作为服务客服的产研团队,我们希望能提供更利于客服操作的系统,其中操作费力度就是我们参考的重要指标,基于此目标,也在不断的迭代和升级产品。

费力度的数据表现有很多维度,本篇文章以客服操作的次数来介绍具体的技术实现。比如客服从接待用户进线到最后创建工单的整个操作周期中,客服点击页面的次数。但是由于第三屏数据量过大,我们无法对每个点击事件都进行埋点上报,这无疑是一个很大的工作量,在当下这种场景,全埋点就是一个比较理想的选择。

大致流程:

1.1 通过监听点击事件,导致 dom 变化、请求接口或打开新浏览器页签的行为数据,都需要埋点上报。

1.2 dom 变化、请求接口和开启浏览器页签,通过发布 - 订阅进行事件通信,触发埋点上报。

这套方案把客服点击操作的场景分为三部分:

  • 点击事件造成 dom 变化的,通过 mutationObserver 监听判断;

  • 点击事件重新开启新系统页签(工单系统中其他页签)或浏览器页签的,或者单纯的切到其他系统页签或浏览器页签的,通过监听 visibilitychange 判断;

  • 点击事件后造成了接口请求的,比如一些加密信息,如手机号、地址等,在客服点击后请求接口可以脱敏展示。

需要说明的一点是,埋点上报的数据中要携带点击区域的位置信息,具体规则是页面 - 区域 - 当前元素的文本,这样我们可以推测客服具体操作了什么动作。当然这种方案还是要做一些特殊处理的,比如 iframe 中的客服操作、统计的维度数据——会话 id、弹窗中监听事件等等。

六、最后的思考

在线客服工作台作为一线客服最重要的系统,在收集用户反馈、处理用户问题方面起着举足轻重的作用。本篇文章主要从工作台的核心功能的角度做了介绍。基于目前现状,从系统开发成本、系统扩展灵活性、业务降本增效三个角度看,谈下未来有价值的事情。

1、由于历史原因,客服 IM 是通过 iframe 内嵌的方式存在于工作台系统中,虽然这种方式在目前没有带来明显的功能问题,但开发成本和性能方面,它仍然是一个重要的优化方向。

2、快速提供排查问题的信息、提供统一且便利的交互操作、适配转转各种复杂业务场景的诉求,是工作台扩展灵活性的目标。比如,针对售前售中售后、手机平板奢侈品等不同时机、不同品类的咨询,能提供灵活的 sop 流程操作,对系统的价值提升无疑是巨大的。

3、从业务角度出发,客服工作台除了要助力客服高质量的解决用户问题,还应该高效的解决问题。随着人工智能技术的普及,在客服领域的应用得到大力的施展,工作台目前已经接入 AI 辅助、智能推荐回复等功能,未来系统可以在更多方面拥抱人工智能,大幅提升客服的工作效率。

想了解更多转转公司的业务实践,点击关注下方的公众号吧!