|
作者:王红艳、职荣豪前言业务背景现状目标技术方案整体架构图核心设计与实现一.多级分类树排序展示/交互设计二.树形设计与tag交互三.分页器/卡片设计项目价值前言为了有效了解用户需求、提升用户体验并获取宝贵的洞察,积极倾听客户的反馈,并基于这些反馈进行产品的运营和调整。这种以市场为导向的方法被称为"客户之声",即VoiceofCustomer(VOC)。因此开发了VOC用户洞察平台,旨在优化业务并实现更高水平的客户满意度。业务背景现状过去业务依赖客服打标的会话数据分析和评估优化结果,对客服人力造成负担。多达10个以上的用户反馈渠道未被有效使用,渠道间反馈采集和数据管理方式均不同,影响全局分析效率。目标在数字指标之外,建立可统一管理全渠道VOC反馈的用户洞察和体验管理平台,聚焦于更全面、真实和标准的体验场景呈现,为用户导向提供核心内容支撑。期望长期根据业务的使用需求迭代分析能力,帮助业务高效精准的定位用户体验的影响因素和改进点,探索前置性的体验提升方法。技术方案整体架构图主要功能包括:总览和VOC明细。总览主要用来查看各个业务线在不同渠道状态下的VOC分类趋势图、排行榜、重点CASE等。VOC明细主要用来查看不同VOC分类下的反馈明细(文本/语音/图片/视频)等,并支持来源信息、商品信息、用户画像等信息的查看。架构图如下:整体架构图核心设计与实现一.多级分类树排序展示/交互设计交互效果图分类树交互图根据需求交互进行数据结构定义核心设计思路:数据结构+算法、“数据模型驱动视图”的思想交互诉求:二级分类节点 直接css控制展示在某列的top即可三级分类节点 由于需要支持展开/收起,并且收到子节点 展开/收起影响,需要补充空白占位节点 进行展示隐藏四级分类节点 需要支持展开收起能力, 并且控制父节点的空白节点展示隐藏原生分类 主要与四级节点id 绑定,收到 三级分类节点、四级分类节点控制具体数据结构定义:// 三级分类树节点字段定义:[{...item, // 业务信息展示id, // 节点id x-xdefalutDisplay, // 是否默认展示hasExpendBtn, // 是否有展开/收起按钮expend, // 展开/收起标记display, // 是否展示}]// 三级分类树空白节点字段定义:[{ id, // 对应子节点id x-xisWhiteSpace, // 表示占位节点 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]) // 第一页隐藏左箭头 // 最后一页隐藏右翻页 = total / pageSize ? 'none' : '' }} className="arrow-right" />卡片设计交互效果图:卡片和反馈趋势图是独立的组件,它们在交互中相互关联。当用户点击卡片时,我们需要更新反馈趋势图以显示相关数据。为了避免重复调用接口,我们利用了dva本身的特性:基于React和Redux的数据流方案来管理Voc(VoiceoftheCustomer)相关的状态和副作用通过使用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 romise((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 romise.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 VocModel2.卡片与折线图联动页面加载后,为每个卡片分配独特的选中色,默认选中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供全集团体验职能应用
|
|