作者:王红艳、职荣豪
前言
业务背景
现状
目标
技术方案
整体架构图
核心设计与实现
一. 多级分类树排序展示/交互设计
二. 树形设计与 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])
// 第一页隐藏左箭头
<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. 卡片与折线图联动
页面加载后,为每个卡片分配独特的选中色,默认选中 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 供全集团体验职能应用