转转VOC实践之路-前端篇

02-27 1230阅读 0评论

作者:王红艳、职荣豪

转转VOC实践之路-前端篇,转转VOC实践之路-前端篇,词库加载错误:未能找到文件“C:\Users\Administrator\Desktop\火车头9.8破解版\Configuration\Dict_Stopwords.txt”。,使用,我们,管理,第1张
(图片来源网络,侵删)
  • 前言

  • 业务背景

    • 现状

    • 目标

  • 技术方案

    • 整体架构图

      转转VOC实践之路-前端篇,转转VOC实践之路-前端篇,词库加载错误:未能找到文件“C:\Users\Administrator\Desktop\火车头9.8破解版\Configuration\Dict_Stopwords.txt”。,使用,我们,管理,第2张
      (图片来源网络,侵删)
  • 核心设计与实现

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

    • 二. 树形设计与 tag 交互

    • 三. 分页器/卡片设计

  • 项目价值

    前言

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

    转转VOC实践之路-前端篇,转转VOC实践之路-前端篇,词库加载错误:未能找到文件“C:\Users\Administrator\Desktop\火车头9.8破解版\Configuration\Dict_Stopwords.txt”。,使用,我们,管理,第3张
    (图片来源网络,侵删)

    业务背景

    现状

    • 过去业务依赖客服打标的会话数据分析和评估优化结果,对客服人力造成负担。

    • 多达 10 个以上的用户反馈渠道未被有效使用,渠道间反馈采集和数据管理方式均不同,影响全局分析效率。

      目标

      • 在数字指标之外,建立可统一管理全渠道 VOC 反馈的用户洞察和体验管理平台,聚焦于更全面、真实和标准的体验场景呈现,为用户导向提供核心内容支撑。

      • 期望长期根据业务的使用需求迭代分析能力,帮助业务高效精准的定位用户体验的影响因素和改进点,探索前置性的体验提升方法。

        技术方案

        整体架构图

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

        转转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)
            }]
            分类树处理流程图
            转转VOC实践之路-前端篇 分类树处理流程图
            树形节点转换/操作处理核心代码
            // 多叉树转换/基本操作基础类
            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 交互

            交互效果图:

            转转VOC实践之路-前端篇

            下拉分类交互图
            交互诉求:
            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
            }
            处理流程图:
            转转VOC实践之路-前端篇 分类树交互图

            三. 分页器/卡片设计

            交互效果图:
            转转VOC实践之路-前端篇 分页器交互图
            分页器设计
            1. 使用useMemo钩子函数创建一个记忆化的函数组件,以便对结果进行缓存和优化性能

            2. 在函数组件中,根据依赖项currentPage、cardOptions和pageSize进行计算和切片操作

            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({ key: Math.random() })
                  })
                return temp
              }, [currentPage, cardOptions, pageSize])
              // 第一页隐藏左箭头
             { display: currentPage == 1 ? 'none' : '' }}
                className="arrow-left"
              /
              // 最后一页隐藏右翻页
              { display: currentPage = total / pageSize ? 'none' : '' }}
                className="arrow-right"
              />
            卡片设计
            交互效果图:

            转转VOC实践之路-前端篇卡片和反馈趋势图是独立的组件,它们在交互中相互关联。当用户点击卡片时,我们需要更新反馈趋势图以显示相关数据。为了避免重复调用接口,我们利用了 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 供全集团体验职能应用


免责声明
本网站所收集的部分公开资料来源于AI生成和互联网,转载的目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。
文章版权声明:除非注明,否则均为主机测评原创文章,转载或复制请以超链接形式并注明出处。

发表评论

快捷回复: 表情:
评论列表 (暂无评论,1230人围观)

还没有评论,来说两句吧...

目录[+]