转转VOC实践之路-前端篇

文摘   科技   2024-01-19 17:00   北京  

作者:王红艳、职荣豪


  • 前言

  • 业务背景

    • 现状

    • 目标

  • 技术方案

    • 整体架构图

  • 核心设计与实现

    • 一. 多级分类树排序展示/交互设计

    • 二. 树形设计与 tag 交互

    • 三. 分页器/卡片设计

  • 项目价值

前言

为了有效了解用户需求、提升用户体验并获取宝贵的洞察,积极倾听客户的反馈,并基于这些反馈进行产品的运营和调整。这种以市场为导向的方法被称为"客户之声",即 Voice of Customer(VOC)。因此开发了 VOC 用户洞察平台,旨在优化业务并实现更高水平的客户满意度。

业务背景

现状

  • 过去业务依赖客服打标的会话数据分析和评估优化结果,对客服人力造成负担。
  • 多达 10 个以上的用户反馈渠道未被有效使用,渠道间反馈采集和数据管理方式均不同,影响全局分析效率。

目标

  • 在数字指标之外,建立可统一管理全渠道 VOC 反馈的用户洞察和体验管理平台,聚焦于更全面、真实和标准的体验场景呈现,为用户导向提供核心内容支撑。
  • 期望长期根据业务的使用需求迭代分析能力,帮助业务高效精准的定位用户体验的影响因素和改进点,探索前置性的体验提升方法。

技术方案

整体架构图

主要功能包括:总览和 VOC 明细。
总览主要用来查看各个业务线在不同渠道状态下的 VOC 分类趋势图、排行榜、重点 CASE 等。
VOC 明细主要用来查看不同 VOC 分类下的反馈明细(文本/语音/图片/视频)等,并支持来源信息、商品信息、用户画像等信息的查看。架构图如下:

整体架构图

核心设计与实现

一. 多级分类树排序展示/交互设计

交互效果图

分类树交互图

根据需求交互进行数据结构定义

核心设计思路:数据结构+算法、 “数据模型驱动视图”的思想

  • 交互诉求:
  1. 二级分类节点   直接 css 控制展示在某列的 top 即可
  2. 三级分类节点   由于需要支持展开/收起,并且收到子节点   展开/收起影响,需要补充空白占位节点   进行展示隐藏
  3. 四级分类节点   需要支持展开收起能力,  并且控制父节点的空白节点展示隐藏
  4. 原生分类   主要与四级节点 id  绑定,收到   三级分类节点、四级分类节点控制
  • 具体数据结构定义:
// 三级分类树节点字段定义:
[{
...item,  // 业务信息展示
id, // 节点id x-x
defalutDisplay, // 是否默认展示
hasExpendBtn,  // 是否有展开/收起按钮
expend,  //  展开/收起标记
display,  // 是否展示
}]
// 三级分类树空白节点字段定义:
[{  
id,  // 对应子节点id  x-x
isWhiteSpace, // 表示占位节点  
diaplay // 是否展示
}]
// 四级树节点字段定义:
[{
...item,  // 业务信息展示
id, // 节点id x-x-x 
defalutDisplay, // 是否默认展示
hasExpendBtn,  // 是否有展开/收起按钮
expend,  //  展开/收起标记
display,  // 是否展示
preDisplay, // 父级节点是否展示
//(父节点preDisplay优先级高于该节点display,保留display字段是为了满足需要// 保存该节点展示隐藏状态的case)
}]

分类树处理流程图

分类树处理流程图

树形节点转换/操作处理核心代码

// 多叉树转换/基本操作基础类

class NodeOp {
  constructor() {}
// 多叉树转换成便于table展示的列表结构
  tree2list() {}
// 查id对应节点
  findNodeById() {}
// 查兄弟节点
  findSiblingById() {}
// 查子节点
  findChildrenByIds() {}
// 查空白节点
  findWhiteNodesByIds() {}
// 查原声节点
  findExamplesByIds() {}
}

// 孙子节点展开操作
N2NodeExpend(listData = [], index = 0, val = {}) {
    const treeData = listData[index]
    const curNode = this.findNodeById(treeData, val.id)
    // 节点展开/收起状态
    curNode.expanded = !curNode.expanded
    // 兄弟节点状态
    const sibling = this.findSiblingById(treeData.childrenN2, curNode.id)
    sibling.forEach((val) => ((val.display = true), (val.preDisplay = true)))
    const ids = sibling.map((v) => v.id)
    // 父级空白节点状态
    const whiteNodes = this.findWhiteNodesByIds(treeData.childrenN1, ids)
    whiteNodes.forEach((v) => (v.display = true))
    // 用户原声节点状态
    const examples = this.findExamplesByIds(treeData.vocVoiceRespList, ids)
    examples.forEach((val) => (val.display = true))
    const newListData = JSON.parse(JSON.stringify(listData))
    return newListData
}

// 子节点收起操作
N1NodeCollapsed(listData = [], index = 0, val = {}) {
    const treeData = listData[index]
    const curNode = this.findNodeById(treeData, val.id)
    // 节点展开/收起状态
    curNode.expanded = !curNode.expanded
    // 兄弟节点状态
    const sibling = this.findSiblingById(treeData.childrenN1, curNode.id)
    sibling.forEach((val) => (val.display = false))
    // 子节点状态
    const children = treeData.childrenN2.filter((v) => !v.defaultDisplay)
    children.forEach((v) => (v.preDisplay = false))
    // 重置第一个默认子节点状态
    treeData.childrenN2.filter((v) => v.defaultDisplay)[0].expanded = false
    const ids = children.filter((v) => !v.preDisplay || !v.display).map((v) => v.id)
    // 兄弟空白节点状态
    const whiteNodes = this.findWhiteNodesByIds(treeData.childrenN1, ids)
    whiteNodes.forEach((v) => (v.display = false))
    // 原声节点状态
    const examples = this.findExamplesByIds(treeData.vocVoiceRespList, ids)
    examples.forEach((val) => (val.display = false))
    const newListData = JSON.parse(JSON.stringify(listData))
    return newListData
}

二. 树形设计与 tag 交互

交互效果图:

下拉分类交互图

交互诉求:

  1. 不同节点的子节点有可能 id 值一样,因此每个节点的唯一 key 值须拼接父节点的 id 值,如第四层子节点的 key=1- 2-3-4
  2. 选中不同层级的节点/子节点获取当前节点的唯一 key 值
  3. 利用选中当前的节点/子节点的唯一 key 值,在下拉多选树里解析出来当前节点的 label 名称,遵循(如果选中节点的 level==1,直接取选中节点名称拼接/全部,否则取选中节点父级名称/选中节点名称)原则展示在 tag 区域
  4. tag 区域删除单个 tag 后,下拉多选回填和选中都要改变

选中节点核心代码

// 下拉多选数据结构
  list:[
     {
        key'1',
        label'第1个分类',
        level'1',   //节点所属层级
        children: [   //子节点
          {
            key'1-2',
            label'分类子节点',
            level'2',   //节点所属层级 
          }
        ]
      }
  ]

// 根据节点唯一key值拼接tag展示
export const findLabel = (labels: any, targetLabels: any) => {
// 创建了一个空的`labelMap`,用于存储标签ID和标签名称之间的映射关系。
  const labelMap = new Map()
  const traverseTree = (tree) => {
    for (const node of tree) {
      const { labelId, labelName, children } = node
      labelMap.set(labelId, labelName)

      if (children.length > 0) {
        traverseTree(children)
      }
    }
  }
  traverseTree(labels)
  const result = targetLabels.map((target) => {
    const { labelId, level } = target
    let labelPath = ''
    let idPath = ''
    // 如果选中节点的level==1,直接取选中节点名称拼接/全部
    if (level === 1) {
      const labelName = labelMap.get(labelId) || ''
      labelPath = labelName + '/全部'
      idPath = labelId
    } else {
      // 其他情况都取选中节点父级名称/选中节点名称
      const parentLabelId = getParentLabelId(labelId, level)
      const parentLabelName = labelMap.get(parentLabelId) || ''
      labelPath = parentLabelName + '/' + labelMap.get(labelId)
      idPath = labelId
    }
    return { label: labelPath, id: idPath }
  })
  return result
}

处理流程图:

分类树交互图

三. 分页器/卡片设计

交互效果图:

分页器交互图

分页器设计

  1. 使用useMemo钩子函数创建一个记忆化的函数组件,以便对结果进行缓存和优化性能
  2. 在函数组件中,根据依赖项currentPagecardOptionspageSize进行计算和切片操作
  3. 创建一个长度为pageSize - temp.length的数组,并使用fill方法填充为空对象{}
  4. 使用forEach方法遍历刚创建的数组,利用Math.random()生成一个随机的key属性,将其添加到temp数组末尾
  5. 最后将计算得到的temp数组作为结果返回

分页器核心代码

// 计算每页的数量
 useLayoutEffect(() => {
   // 获取dom中引用元素的宽度
    const size = refCard.current?.getBoundingClientRect()
    // 获取每页展示数量
    const page = Math.floor(size?.width / (175 + 10))
    if (!isNaN(page)) {
      setPageSize(page)
    }
  }, [cardOptions])
  // 计算每页显示的内容
  const cardOptionsList = useMemo(() => {
    // cardOptions的数组中提取当前页码对应的部分数据
    const temp = cardOptions?.slice(
      (currentPage - 1) * pageSize,
      (currentPage - 1) * pageSize + pageSize
    )
    // 创建一个长度为pageSize - temp.length的数组
    Array(pageSize - temp.length)
      .fill({})
      .forEach(() => {
        temp.push({ keyMath.random() })
      })
    return temp
  }, [currentPage, cardOptions, pageSize])
  // 第一页隐藏左箭头
 <LeftOutlined
    onClick={clickLeft}
    style={{ display: currentPage == 1 ? 'none' : '' }}
    className="arrow-left"
  />
  // 最后一页隐藏右翻页
  <RightOutlined
    onClick={clickRight}
    style={{ display: currentPage >
= total / pageSize ? 'none' : '' }}
    className="arrow-right"
  />

卡片设计

交互效果图:

卡片和反馈趋势图是独立的组件,它们在交互中相互关联。当用户点击卡片时,我们需要更新反馈趋势图以显示相关数据。为了避免重复调用接口,我们利用了 dva 本身的特性:

  • 基于 React 和 Redux 的数据流方案来管理 Voc(Voice of the Customer)相关的状态和副作用
  • 通过使用 dva 的 model 定义,能够更加高效地处理和跟踪 Voc 数据,并实现数据的组织、更新和呈现
  • 借助 dva 的优势,可以简化数据管理的复杂性,并实现数据与 UI 的同步更新,提供更好的用户体验
1. models 存储/处理数据

核心代码:

import { postTrend } from '@/services/vocboard'
const VocModel = {
  namespace'voc'//model的名称,必须是唯一的标记
  state: {
    trends: []
  },
  effects: {
    *postTrend({ params }, { call, put }) {
      const { id = [], firstMarkingId = '' } = params
      const postTrendPromise = (params) => {
        return new Promise((resolve, reject) => {
          postTrend(params)
            .then((value) => {
              resolve(value?.trends)
            })
            .catch((e) => reject(e))
        })
      }
      // 根据返回参数不同给后端传不同的参数
      const getApi = () => {
        const promises =
          firstMarkingId == 'all'
            ? id.map((item) => postTrendPromise({ ...params, firstMarkingId: item }))
            : id.map((item) =>
              postTrendPromise({ ...params, firstMarkingId, secondMarkingId: item })
            )
        // 请求所有接口,统一返回数据
        return Promise.all(promises)
      }
      // 获取最终的数据
      const data = yield call(getApi)
      yield put({
        type'saveData',
        payload: data || {}
      }) //通过reducers去修改本model里面的数据
    }
  },
  reducers: {
    saveData(state, action) {
      const arr = [...new Set(action.payload.map((item) => item.markingCateId))]
      return {
        ...state,
        trends: action.payload || []
      }
    }
  }
}
export default VocModel
2. 卡片与折线图联动
  1. 页面加载后,为每个卡片分配独特的选中色,默认选中 3 个卡片
  2. 用户可以自由选择和取消选择卡片
  3. 最少可以一个都不选,最多可以选中 8 个卡片

联动核心代码:

  const result = cardList?.map((item, index) => {
      var colorIndex = index % colorList.length // 计算颜色索引
      // 增加颜色字段
      return {
        markingCateId: item.markingCateId,
        name: item.name,
        qoq: item.qoq,
        unusualChange: item.unusualChange,
        value: item.value,
        color: colorList[colorIndex]
      }
    })

    const handleCard = (val) => {
    // 判断是选中还是取消选中
    if (idOptions.includes(val)) {
      dispatch({
        type'voc/postTrend',
        params: {
          ...currSearch,
          id: removeID(val, idOptions)
        }
      })
      // 取消选中的时候移除暂存的ID
      setIdOptions(removeID(val, idOptions))
    } else {
      // 最多选择8个
      if (idOptions?.length > 8) {
        return message.warning('最多能选择8个')
      }
      dispatch({
        type'voc/postTrend',
        params: {
          ...currSearch,
          id: idOptions
        }
      })
      setIdOptions([...idOptions, val])
    }
  }

项目价值

项目一期逐步推广使用(完成 B2C 侧 1+4+n 全渠道 voc 整合和分类管理)

  • 首次在公司层面打通了各渠道的 VOC,除了商家 IM 均已接入
  • 在 B2C 交易模式下进行试点推广,截止元旦后已完成 70%VOC 分类覆盖度
  • B2C 侧反响积极,开始搭建负向反馈率相关平台服务指标,助力业务商家治理
  • 践行“用户第一”的企业文化,提供最全面、最真实的用户洞察工具
  • 整合转转 app 全渠道的用户反馈,日均管理 7 万+voc 供全集团体验职能应用


大转转FE
定期分享一些团队对前端的想法与沉淀
 最新文章