本篇要记录的是一个完整功能里的其中一部分,这个功能需求大致是在移动端给所选分组下的管理员用户留言,管理员用户可在PC端对留言用户进行回复,而这个功能中除了移动端和PC管理端的列表操作,还涉及一个重要的功能模块——带搜索功能的树状结构组织选择器,大概长下图这样:
由于移动端项目之前没有这种功能的存量代码,因此需要从零开始手动实现。下面开始记录实现过程和思路。
目录
分析需求
具体实现
页面结构搭建
逻辑实现
更新下一级数据
更新上一级数据
更新顶级数据
该功能的需求为页面载入后先展示最顶级的分组列表,没有下级分组的列表项不可点击,不显示“>”符号,点击其中一项展示该分组下的分组列表,需覆盖点击之前的列表内容而非采用层级嵌套的形式,并在分组列表顶部展示当前点击的分组路径,点击之前的路径可返回上级分组。
看到这个需求首先能想到的就是树形结构、递归和数组操作,需要做的就是根据每次点击所选择的分组项,更新当前的数据节点。
移动端组件库使用了Vant,基本需求都能满足,组件的具体使用就不做赘述,直接贴代码。
本页的结构并不复杂,主要就是搜索框、组织路径,以及展示列表区块
组织选择页
点击搜索框后跳转至搜索页面,结构于选择页类似,多了搜索建议关键词和搜索结果关键词高亮等。
组织搜索页
{{ searchTip }}
{{ companyName }}
{{ item.name }}
_
暂无结果,请换个关键词搜索
{{ item.orgFullName }}
写树形结构的逻辑之前,需要先了解一下相关的后端接口,根据接口返回的数据结构再决定怎么定义数据结构和递归逻辑。
经过一顿筛选,最终需要用到的接口筛出来了3个,分别是返回最顶级组织对象的接口getOrgsData、返回搜索结果数组对象的接口getTreeData,以及根据传入的组织id返回对应组织信息及所有子组织信息的接口getOrgsList。
OK,知道了3个接口各自返回的数据结构,接下来就开始正式理逻辑。
在组织选择页面,首先需要做的是调用getOrgsData接口,获取最顶级组织的数据渲染在页面上。考虑到组织架构的数据变动并不是很频繁,这里使用了前端缓存处理所有请求数据。首先在created和activated钩子执行初始化顶级组织数据initOrg方法。在initOrg方法内做了缓存判断,如果命中Vuex中存储的前端缓存,就取缓存中的数据,否则才请求服务端数据。请求数据的方法requestOrgInfo中将请求返回的数据存进store中,同时设置已存储的flag为true,这个flag用于手动刷新清缓存的操作。
import {getOrgsData, getTreeData} from "@/views/secretaryMailbox/js/api";
export default {
...
data() {
return {
backClicked: 0,//是否点击了返回按钮
orgTree: [],//组织树
topOrg: [],//顶级组织
selectedOrg: {},//所选组织
searchOrgPath: [], //搜索路径
orgTreeUpdated: false, //组织树是否已更新
transitionType: 0,//动画过渡类型
loading: false,//载入状态
prevPath: {},//上级路径
refreshing: false, //刷新状态
hasRefreshed: false, //完成手动刷新
orgPath: [
// {
// orgName:'',
// orgId:''
// }
],//组织路径
treeCache: this.$store.state.orgListModule.treeCache || {}//组织树缓存
}
},
methods: {
...
//请求顶级组织列表
async requestOrgInfo() {
let topOrg = await getOrgsData({
companyId: this.$store.state.userInfo.companyId,
orgCode: '0',
orgName: ''
})
this.$store.commit('orgListModule/setTopOrgSavedStatus', true)
this.$store.commit('orgListModule/setTopOrg', topOrg.content)
this.topOrg = topOrg.content
this.orgTree = topOrg.content
},
//初始化最顶级组织列表
async initOrg() {
// 组织选择页手动刷新记录flag重置
this.setOrgListRefreshedStatus(false)
//确保只请求一次顶级组织数据
this.loading = true
let isTopOrgSaved = this.$store.state.orgListModule.isTopOrgSaved
let topOrgList = this.$store.state.orgListModule.topOrg
if (!isTopOrgSaved) {
await this.requestOrgInfo()
} else {
this.topOrg = topOrgList
this.orgTree = topOrgList
}
this.loading = false
this.selectedOrg = this.orgTree[0]
this.confirmOrgItem(this.orgTree[0])
},
...
},
computed: {
//动画类型
transition() {
switch (this.transitionType) {
default:
case 0:
return ''
case 1:
return 'slideIn'
case 2:
return 'slideOut'
case 3:
return 'slideInBack'
}
}
},
watch: {
//监听组织路径变化,根据其高度自适应设置搜索结果max-height值
orgPath(val) {
let length = val.length
if (length > 0) {
this.setOrgContainerHeight()
}
}
},
async created() {
await this.initOrg()
},
async activated() {
//初始化切换动画类型为0
this.transitionType = 0
//重新载入时初始化顶级组织
this.navBack()
await this.initOrg()
},
}
根据应用实际情况,前端缓存的Vuex未结合localStorage进行持久化,module配置如下:
let orgList = {
namespaced: true,
state: {
orgList: [],
treeCache: {},
isTopOrgSaved:false,
topOrg:[],
isOrgListSaved: false,
adminInfo: [],
adminMailData:{
adminInfo:[]
},
adminMailDataUpdated:false,
hasOrgListRefreshed:false //组织选择界面手动刷新flag
},
mutations: {
//设置组织列表缓存
setOrgList(state, data) {
state.orgList = data
},
//设置顶级组织缓存
setTopOrg(state,data){
state.topOrg = data
},
//设置组织树缓存
setTreeCache(state, data) {
state.treeCache = data
},
//设置组织列表状态,是否已保存
setOrgListStatus(state, data) {
state.isOrgListSaved = data
},
//设置管理员缓存信息,每选择一个组织并请求完成后push一条新数据
setAdminInfo(state, data) {
if (state.adminInfo.length > 0 &&
state.adminInfo.map(item => item.orgCode).includes(data.orgCode)){
return
}
state.adminInfo.push(data)
},
//重置管理员信息缓存,用于手动下拉请求新数据,只针对当前所选组织重置,不影响其他组织的缓存数据
resetAdminInfo(state,data) {
state.adminInfo = state.adminInfo.filter(item=>item.orgCode!==data)
},
//设置管理员信息数据缓存
setAdminMailData(state, data){
state.adminMailData = data
},
//设置管理员信息数据缓存保存状态
setAdminMailDataStatus(state, data){
state.adminMailDataUpdated = data
},
//设置顶级组织保存状态
setTopOrgSavedStatus(state,data){
state.isTopOrgSaved = data
},
//设置组织界面手动刷新flag
setOrgListRefreshedStatus(state,data){
state.hasOrgListRefreshed = data
}
}
}
export default orgList;
接下来要处理的就是点击顶级组织中某一个列表项展示子组织数据,以及更新组织选择的导航路径。
import {getOrgsData, getTreeData} from "@/views/secretaryMailbox/js/api";
export default {
...
data() {
return {
backClicked: 0,//是否点击了返回按钮
orgTree: [],//当前组织树
topOrg: [],//顶级组织
selectedOrg: {},//所选组织
searchOrgPath: [], //搜索路径
orgTreeUpdated: false, //组织树是否已更新
transitionType: 0,//动画过渡类型
loading: false,//载入状态
prevPath: {},//上级路径
refreshing: false, //刷新状态
hasRefreshed: false, //完成手动刷新
orgPath: [
// {
// orgName:'',
// orgId:''
// }
],//组织路径
treeCache: this.$store.state.orgListModule.treeCache || {}//组织树缓存
}
},
methods: {
...
//选择组织
async selectOrg(orgItem) {
if (this.searchValue) this.searchValue = ''
// 如果有子组织(右侧显示">"符号),执行更新列表方法,否则直接确认选择当前组织
if (this.isShowLink(orgItem)) {
await this.updateTreeData(orgItem)
} else {
this.confirmOrgItem(orgItem)
}
},
//导航路径前进
navForward(orgItem) {
this.orgPath.push({id: orgItem.id || orgItem.orgCode, name: orgItem.orgFullName})
},
//导航路径后退
navBack(orgItem) {
let index = orgItem ? this.orgPath.indexOf(this.orgPath.find(item => item.id === orgItem.id || orgItem.orgCode)) : -1
this.prevPath = this.orgPath[index + 1]
this.orgPath = this.orgPath.slice(0, index + 1)
},
//更新组织树数据
async updateTreeData(orgItem, type = 1) {
//type 1:下一级 2:上一级 3:回到顶级
switch (type) {
case 1:
default:
await this.updateNextPath(orgItem, 150)
break
case 2:
await this.updatePrevPath(orgItem, 150)
break
case 3:
await this.updateTopPath(150)
break;
}
},
//更新下一级数据
async updateNextPath(orgItem, timeout) {
this.transitionType = 2
setTimeout(async () => {
if (!this.orgPath) this.orgPath = []
if (this.orgPath.length <= 0) {
await this.getTreeData(orgItem)
} else {
this.renderChildNode(orgItem)
}
if (orgItem.orgFullPath) {
this.orgTree.forEach(item => item.orgFullPath = orgItem.orgFullPath.concat({
id: orgItem.orgCode || orgItem.id,
name: orgItem.orgFullName
}))
}
this.confirmOrgItem(this.orgTree[0])
this.transitionType = 1
}, timeout)
},
//更新上一级数据
async updatePrevPath(orgItem, timeout) {
// 点击当前组织层级不可返回
if (orgItem.name === this.orgPath[this.orgPath.length - 1].name) return
this.transitionType = 2
setTimeout(() => {
this.renderParentNode(orgItem)
this.transitionType = 3
this.confirmOrgItem(this.orgTree[0])
}, timeout)
},
//更新顶级数据
async updateTopPath(timeout) {
this.transitionType = 2
setTimeout(async () => {
await this.renderTopTree()
this.transitionType = 3
this.confirmOrgItem(this.orgTree[0])
}, timeout)
},
//请求顶级组织列表
async requestOrgInfo() {
let topOrg = await getOrgsData({
companyId: this.$store.state.userInfo.companyId,
orgCode: '0',
orgName: ''
})
this.$store.commit('orgListModule/setTopOrgSavedStatus', true)
this.$store.commit('orgListModule/setTopOrg', topOrg.content)
this.topOrg = topOrg.content
this.orgTree = topOrg.content
},
//初始化最顶级组织列表
async initOrg() {
// 组织选择页手动刷新记录flag重置
this.setOrgListRefreshedStatus(false)
//确保只请求一次顶级组织数据
this.loading = true
let isTopOrgSaved = this.$store.state.orgListModule.isTopOrgSaved
let topOrgList = this.$store.state.orgListModule.topOrg
if (!isTopOrgSaved) {
await this.requestOrgInfo()
} else {
this.topOrg = topOrgList
this.orgTree = topOrgList
}
this.loading = false
this.selectedOrg = this.orgTree[0]
this.confirmOrgItem(this.orgTree[0])
},
...
},
computed: {
//动画类型
transition() {
switch (this.transitionType) {
default:
case 0:
return ''
case 1:
return 'slideIn'
case 2:
return 'slideOut'
case 3:
return 'slideInBack'
}
}
},
watch: {
//监听组织路径变化,根据其高度自适应设置搜索结果max-height值
orgPath(val) {
let length = val.length
if (length > 0) {
this.setOrgContainerHeight()
}
}
},
async created() {
await this.initOrg()
},
async activated() {
//初始化切换动画类型为0
this.transitionType = 0
//重新载入时初始化顶级组织
this.navBack()
await this.initOrg()
},
}
根据操作类型的不同,更新组织树数据的updateTreeData方法需要处理3种不同情况:
1.更新为下一级组织列表数据
2.更新为上一级组织列表数据
3.更新为顶级组织数据
updateTreeData方法的第二个参数为执行动画效果的延迟,动画参照了iOS的设置页切换效果,下一级为从右往左切换进入,上一级为从左往右切换进入,这个放到后面细说。
变量orgPath用于记录当前选择的组织路径,默认为空(处于顶级组织)。进入下一级调用navForward方法,往orgPath中push一条格式为{orgName:'xxx',orgId:'xxx' }的数据,而进入上一级调用navBack,从orgPath数组中删除掉最后一条数据。
接下来,就是整个过程中的核心部分,递归实现树形结构的数据处理。
...
mounted(){
...
// 获取组织树
async getTreeData(orgItem) {
this.loading = true
let treeData = null
this.navForward(orgItem)
//如果缓存对象中有数据,就取缓存,否则请求服务端数据
let hasCache = Object.keys(this.treeCache).includes(orgItem.id || orgItem.orgCode)
if (hasCache) {
treeData = this.treeCache[orgItem.id || orgItem.orgCode]
this.orgTree = treeData
this.confirmOrgItem(this.orgTree[0])
} else {
await this.requestOrgTree(this.treeCache, orgItem)
}
this.loading = false
},
//渲染子节点
renderChildNode(orgItem) {
let parentNode = this.orgTree.slice()
this.navForward(orgItem)
this.orgTree = this.orgTree.find(item => item.id === orgItem.id || orgItem.orgCode).children?.slice()
this.recordParentNode(parentNode)
},
//记录每条数据父节点
recordParentNode(parentNode) {
this.orgTree.forEach(item => item.parentNode = parentNode)
},
//渲染父节点
renderParentNode(orgItem) {
if (this.orgPath[this.orgPath.length - 1].id === orgItem.id || orgItem.orgCode) return
this.navBack(orgItem)
this.matchParentNode(this.prevPath, this.orgTree[0].parentNode)
},
//匹配父节点
matchParentNode(orgItem, parent) {
// 递归出口,匹配到与当前路径一致的对象
let parentNode = parent.find(item => item.orgFullName === orgItem.name)
if (parentNode) {
this.orgTree = parent
// 递归向上查找父节点
} else {
parent = parent[0].parentNode
this.matchParentNode(orgItem, parent)
}
},
//渲染顶级树
async renderTopTree(callback) {
// 当前组织层级为顶级组织时直接return
if (this.orgTree[0].orgPath.length <= 4) return
this.transitionType = 2
setTimeout(async () => {
this.navBack()
await this.initOrg()
this.transitionType = 3
}, 100)
typeof callback === 'function' && callback()
},
//更新下一级数据
async updateNextPath(orgItem, timeout) {
this.transitionType = 2
setTimeout(async () => {
if (!this.orgPath) this.orgPath = []
if (this.orgPath.length <= 0) {
await this.getTreeData(orgItem)
} else {
this.renderChildNode(orgItem)
}
if (orgItem.orgFullPath) {
this.orgTree.forEach(item => item.orgFullPath = orgItem.orgFullPath.concat({
id: orgItem.orgCode || orgItem.id,
name: orgItem.orgFullName
}))
}
this.confirmOrgItem(this.orgTree[0])
this.transitionType = 1
}, timeout)
},
//更新上一级数据
async updatePrevPath(orgItem, timeout) {
// 点击当前组织层级不可返回
if (orgItem.name === this.orgPath[this.orgPath.length - 1].name) return
this.transitionType = 2
setTimeout(() => {
this.renderParentNode(orgItem)
this.transitionType = 3
this.confirmOrgItem(this.orgTree[0])
}, timeout)
},
//更新顶级数据
async updateTopPath(timeout) {
this.transitionType = 2
setTimeout(async () => {
await this.renderTopTree()
this.transitionType = 3
this.confirmOrgItem(this.orgTree[0])
}, timeout)
},
//更新组织树数据
async updateTreeData(orgItem, type = 1) {
//type 1:下一级 2:上一级 3:回到顶级
switch (type) {
case 1:
default:
await this.updateNextPath(orgItem, 150)
break
case 2:
await this.updatePrevPath(orgItem, 150)
break
case 3:
await this.updateTopPath(150)
break;
}
},
//导航路径前进
navForward(orgItem) {
this.orgPath.push({id: orgItem.id || orgItem.orgCode, name: orgItem.orgFullName})
},
//导航路径后退
navBack(orgItem) {
let index = orgItem ? this.orgPath.indexOf(this.orgPath.find(item => item.id === orgItem.id || orgItem.orgCode)) : -1
this.prevPath = this.orgPath[index + 1]
this.orgPath = this.orgPath.slice(0, index + 1)
},
//设置组织路径
setOrgFullPath(orgItem, orgTree, pathLevel) {
let path = orgItem.orgPath
if (!('orgFullPath' in orgItem)) {
orgItem.orgFullPath = []
}
if (pathLevel <= 0) return
for (const item of orgTree) {
let step = item.orgPath.length
if (item.children && item.children.length > 0 && item.orgFullName !== orgItem.orgFullName) {
if (item.orgPath.slice(0, step) === path.slice(0, step)) {
orgItem.orgFullPath.push({name: item.orgFullName, id: item.orgCode || item.id})
this.setOrgFullPath(orgItem, item.children, pathLevel - 1)
}
}
}
},
}
在更新下一级组织数据的updateNextPath方法中,如果orgPath为空,即当前路径处于顶级组织,就调用getTreeData方法,请求getTreeData接口,获取所选组织及其所有子组织的树状结构数据,并记录进缓存,下次直接取缓存中的数据。如果orgPath不为空,即当前路径处于某一顶级组织的子组织,则调用renderChildNode方法,渲染子节点数据。由于后端getTreeData接口返回的数据中并未包含父节点,树中每个子节点的父节点parentNode需要前端去记录补全。
renderChildNode方法主要做3件事:
1.更新导航状态
2.更新当前组织列表变量orgTree为选中的子节点下的数据
3.调用recordParentNode为每个子节点记录其所属的父节点
记录父节点的renderParentNode方法很简单,即遍历当前组织列表数组orgTree,将每个元素的父节点parentNode设为当前这个数组orgTree。
后端接口中未提供带组织名称的组织路径,这里也需要前端记录实现,因此updateNextPath还需要手动为当前所选的组织对象orgItem记录一个路径数组对象orgFullPath,格式为[{id:xxx,name:xxx},{id:xxx,name:xxx},{id:xxx,name:xxx}],第一项为最上级组织路径名。
点击组织路径中的上一级路径,会调用updatePrevPath方法更新当前列表为上一级组织数据。当然,在该方法内对点击做了基本的限制,点击路径导航中的最后一个目录也就是当前组织应该直接return。然后延时调用渲染父节点的方法renderParentNode。
renderParentNode方法主要做这三件事
1.判断当前选择的组织是路径最后一个,是则return
2.导航路径更新至上一级
3.调用matchParentNode方法递归匹配父节点数据
这里着重记录一下matchParentNode方法的逻辑。该方法传入两个参数,分别是当前路径对象orgItem(包含orgName和orgId两个属性)和父节点数组对象parent。在matchParentNode方法内,首先定义一个变量parentNode作为递归出口,通过传入的parent对象中orgFullName的值与当前路径对象orgItem的orgName值是否匹配,来判断是否查找到了父节点。如果没有匹配到就一直递归向上查找,每次递归都将parent设为它的父节点数组对象,直到能够匹配到父节点(即parentNode有值)就停止递归,将当前组织列表数组orgTree设为parentNode的值,实现更新渲染当前组织列表内容。
更新顶级数据updateTopPath方法的操作很简单,调用renderTopTree方法,重新初始化一下顶级组织数据。renderTopTree留了个扩展的callback,可以在操作返回顶级组织时通过调用传入的回调函数做一些其他额外处理。
大概效果长这样
写到这发现字数有点多了,还是分两篇记录吧,至于设置组织路径的递归方法setOrgFullPath,以及交互动画实现思路,就放在下一篇组织搜索中写吧。