转转VOC实践之路-前端篇
作者:王红艳、职荣豪
前言
业务背景
现状
目标
技术方案
整体架构图
(图片来源网络,侵删)
核心设计与实现
一. 多级分类树排序展示/交互设计
二. 树形设计与 tag 交互
三. 分页器/卡片设计
项目价值
前言
为了有效了解用户需求、提升用户体验并获取宝贵的洞察,积极倾听客户的反馈,并基于这些反馈进行产品的运营和调整。这种以市场为导向的方法被称为"客户之声",即 Voice of Customer(VOC)。因此开发了 VOC 用户洞察平台,旨在优化业务并实现更高水平的客户满意度。
(图片来源网络,侵删)业务背景
现状
过去业务依赖客服打标的会话数据分析和评估优化结果,对客服人力造成负担。
多达 10 个以上的用户反馈渠道未被有效使用,渠道间反馈采集和数据管理方式均不同,影响全局分析效率。
目标
在数字指标之外,建立可统一管理全渠道 VOC 反馈的用户洞察和体验管理平台,聚焦于更全面、真实和标准的体验场景呈现,为用户导向提供核心内容支撑。
期望长期根据业务的使用需求迭代分析能力,帮助业务高效精准的定位用户体验的影响因素和改进点,探索前置性的体验提升方法。
技术方案
整体架构图
主要功能包括:总览和 VOC 明细。
整体架构图
总览主要用来查看各个业务线在不同渠道状态下的 VOC 分类趋势图、排行榜、重点 CASE 等。
VOC 明细主要用来查看不同 VOC 分类下的反馈明细(文本/语音/图片/视频)等,并支持来源信息、商品信息、用户画像等信息的查看。架构图如下:核心设计与实现
一. 多级分类树排序展示/交互设计
交互效果图
分类树交互图根据需求交互进行数据结构定义
核心设计思路:数据结构+算法、 “数据模型驱动视图”的思想
交互诉求:
二级分类节点 直接 css 控制展示在某列的 top 即可
三级分类节点 由于需要支持展开/收起,并且收到子节点 展开/收起影响,需要补充空白占位节点 进行展示隐藏
四级分类节点 需要支持展开收起能力, 并且控制父节点的空白节点展示隐藏
原生分类 主要与四级节点 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 交互
交互效果图:
下拉分类交互图交互诉求:
不同节点的子节点有可能 id 值一样,因此每个节点的唯一 key 值须拼接父节点的 id 值,如第四层子节点的 key=1- 2-3-4
选中不同层级的节点/子节点获取当前节点的唯一 key 值
利用选中当前的节点/子节点的唯一 key 值,在下拉多选树里解析出来当前节点的 label 名称,遵循(如果选中节点的 level==1,直接取选中节点名称拼接/全部,否则取选中节点父级名称/选中节点名称)原则展示在 tag 区域
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 }
处理流程图:
分类树交互图三. 分页器/卡片设计
交互效果图:
分页器交互图分页器设计
使用useMemo钩子函数创建一个记忆化的函数组件,以便对结果进行缓存和优化性能
在函数组件中,根据依赖项currentPage、cardOptions和pageSize进行计算和切片操作
创建一个长度为pageSize - temp.length的数组,并使用fill方法填充为空对象{}
使用forEach方法遍历刚创建的数组,利用Math.random()生成一个随机的key属性,将其添加到temp数组末尾
最后将计算得到的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" />
卡片设计
交互效果图:
卡片和反馈趋势图是独立的组件,它们在交互中相互关联。当用户点击卡片时,我们需要更新反馈趋势图以显示相关数据。为了避免重复调用接口,我们利用了 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. 卡片与折线图联动
页面加载后,为每个卡片分配独特的选中色,默认选中 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 供全集团体验职能应用
还没有评论,来说两句吧...