前端如何做好ToB系统UI&交互体验
一、背景
ToB系统
,或者另一个较多的叫法中后台系统
,往往采用element-ui
,iview
组件库提高开发效率
该类系统往往是交互逻辑复杂,不怎么注重UI(UI资源充足的团队当我没说),不过不管有没有UI,都比不上C端更注重UI
但身为一名前端,注重页面体验,UI交互是我们的关心的事情,本文结合自身经验讲前端如何做好ToB系统UI&交互体验
二、遇到的问题
如果你是一个系统还好说,乱不到哪里去;多个ToB系统,如果没有一个规范的话很容易做成五花八门
我当时交接过来五个系统,交接前这五个系统是不同部门做的,有用element,有用view;我们把这五个系统联合在一起,做成一个完整的数字化运营解决方案的产品;这交互、UI实在拉垮,且我们没有UI支持
所以自己探索了一些规范,并落地实现
三、UI&交互统一
为了研究到底哪种UI
、交互
是相对来说最好的,我参考了antd pro、element-ui 等其他中后台系统;大概分为以下几类
- loading
- 导航
- 菜单
- 面包屑
- 选项卡切换
- 表格搜索表单
- 表格
1、loading
分为两种,路由切换
时loading,接口请求
时loading,具体分析如下
- 路由切换:可在路由的
钩子函数
中处理- 优点:
统一处理,成本低
- 缺点:对数据是否加载完成
无法精确
- 优点:
- 接口请求:在
初始化页面的接口
增加loading处理,比如查询列表的接口是需要加loading的,但是删除一行的接口不需要加loading- 优点:相对来说可以确保精确
- 缺点:
成本高
注:表格的增删改查,表格应该统一封装,在表格的局部loading,表格相关接口配合设置loading
拿antd pro举例,切换路由时,内容区域
整个开始loading,
路由切换完之后,
表格
开始loading,对表格进行增删改查时,仅表格loading
还有一种比较常见的路由切换loading,在
浏览器的头部
对于这种方式,
iview
提供了iview.LoadingBar可以直接使用,element
可以利用nprogress包,代码如下
step 1:
npm install --save nprogress
step 2:
// router/index.js
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
router.beforeEach((to, from, next) => {
NProgress.start();
next()
})
router.afterEach(to => {
NProgress.done();
})
step 3:
// App.vue
// 将loading颜色改为主题色
#nprogress .bar {
background: #2d8cf0 !important;\
}
2、导航
导航是至关重要的一部分,主要包括头部导航
、侧边菜单导航
,还有面包屑
,以及页面切换选卡
给大家贴几个图感受一下我们未改造之前的体验
菜单
- 一级菜单有图标
- 二级菜单无图标
- 各系统颜色、交互保持一致
- 在当前页,及当前页的子页面菜单均高亮
- 各系统菜单命名、分布有部分调整,现有改动,有不妥可联系
面包屑
- 面包屑根据页面菜单层级展示
- 当前页面为高亮
- 当前页前面页面,鼠标滑入高亮,可点击,点击非根结点,回到该页面,点击跟根点,回到根结点第一个菜单下
3、表格搜索表单
规范如下
- 查询类表单独立成一个小模块放在表格上面,距离表格margin-bottom:16px
- 表单每行三个表单项
- 表单数量大于2时,可收起、展开按钮
- 水平分栏右对齐,垂直每行之间margin:10px
- 【查询】,【重置】放最后一个表单项后,总在其所在行的最右侧
- 操作类(新建、删除)放在表头上面(见下),跟表格视为一个模块
- 按钮之间间距统一为margin:16px
- 查询类表单先【查询】后【重置】;查询按钮为主题色,重置按钮为白色
- 查询表单类操作类按钮统一文案为【查询】【重置 】;不要图标
- 查询表单无label的统一增加label
- 操作类按钮,【新增按钮】根据具体业务命名删除按钮文档统一为【删除】、【批量删除】
- 查询类表单大于两个时,增加展开、收缩功能;默认收缩,收缩后展示一行
- 选项卡放最上面,如下
搜索表单组件代码实现
// form-search/index.vue
<template>
<div class="form-search-container">
<!-- 放选项卡 -->
<slot></slot>
<el-form
class="form-search-container__form"
:inline="true"
label-suffix=":"
:label-width="labelWidth"
:style="value.length % 3 == 0 && !collapse ? 'padding-bottom: 40px;' : ''"
>
<form-search-item
v-for="(item, index) in formList"
:key="index"
:type="item.type"
:label="item.label"
:placeholder="item.placeholder"
:options="item.options"
:name="item.key"
:isSlot="item.isSlot"
:filterable="item.filterable"
v-bind:value.sync="item.value"
>
<template v-slot:[item.key]>
<slot :name="Array.isArray(item.key) ? item.key[0] : item.key"></slot>
</template>
</form-search-item>
<el-form-item
class="form-search-container__item form-search-container__btn"
>
<el-button type="primary" size="medium" @click="handleSearch"
>查询</el-button
>
<el-button @click="resetForm" size="medium">重置</el-button>
<el-button v-if="value.length > 2 && !hideCollapse" type="text" @click="handleCollapse"
>{{ collapse ? '展开' : '收起' }}
<i v-if="!collapse" class="el-icon-arrow-up"></i>
<i v-else class="el-icon-arrow-down"></i>
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import FormSearchItem from './components/form-search-item'
import bus from '../../utils/event-bus'
export default {
components: { FormSearchItem },
props: {
value: {
type: Array,
default: () => [],
},
labelWidth: {
type: String,
default: '80px',
},
hideCollapse: {
type: Boolean,
default: false
}
},
computed: {
formList() {
this.$nextTick(() => {
bus.emit('form-search-collapse')
})
return this.collapse ? this.value.slice(0, 2) : this.value
},
},
data() {
return {
collapse: false,
initObj: {},
}
},
created() {
this.value.map((item) => {
Object.assign(this.initObj, { [item.key]: item.value })
})
},
methods: {
resetForm() {
this.value.map((item) => {
item.value = this.initObj[item.key]
})
this.$emit('update:value', this.value)
this.$emit('reset-click', this.initObj)
},
handleSearch() {
const resObj = {}
this.value.map((item) => {
if (Array.isArray(item.key)) {
item.key.map((keyItem, index) => {
Object.assign(resObj, { [keyItem]: item.value[index] })
})
} else {
Object.assign(resObj, { [item.key]: item.value })
}
})
this.$emit('update:value', this.value)
this.$emit('search-click', resObj)
},
handleCollapse() {
this.collapse = !this.collapse
bus.emit('form-search-collapse')
},
},
}
</script>
<style lang="less" scoped>
.form-search-container {
background-color: #fff;
margin-bottom: 8px;
padding: 16px 10px 0;
}
.form-search-container__form {
display: flex;
flex-wrap: wrap;
position: relative;
}
.form-search-container__item {
flex: 0 0 30%;
height: 32px;
}
.form-search-container__btn {
position: absolute;
right: 0;
bottom: 0;
margin-bottom: 10px;
}
</style>
// form-search/components/form-search-item.vue
<template>
<el-form-item
:label="label"
class="form-search-container__item"
:prop="Array.isArray(name) ? name[0] : name"
>
<slot v-if="isSlot" :name="name"></slot>
<el-input
v-if="type === 'input' && !isSlot"
:placeholder="placeholder"
v-model="value2"
@change="handleChange"
></el-input>
<el-select
v-if="type === 'select'"
v-model="value2"
:placeholder="placeholder"
@change="handleChange"
:filterable="filterable"
>
<el-option
v-for="(item, index) in options.list"
:key="index"
:label="item[options.label || 'label']"
:value="item[options.value || 'value']"
></el-option>
</el-select>
<el-date-picker
v-if="type === 'time'"
type="daterange"
v-model="value2"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="['00:00:00', '23:59:59']"
@change="getTime"
>
</el-date-picker>
</el-form-item>
</template>
<script>
export default {
props: {
formModel: {
type: Object,
default: () => {},
},
label: {
type: String,
default: '',
},
type: {
type: String,
default: 'input', //select
},
placeholder: {
type: String,
default: '',
},
value: {
type: [String, Number, Array],
default: '',
},
options: {
type: Object,
default: () => {
return {
list: [],
label: 'label',
value: 'value',
}
},
},
name: {
type: [String, Array],
default: () => '',
},
isSlot: {
type: Boolean,
default: false,
},
filterable: {
type: Boolean,
default: false,
}
},
data() {
return {
value2: this.value,
}
},
watch: {
value(val) {
this.value2 = val
},
},
methods: {
handleChange() {
this.$emit('update:value', this.value2)
},
getTime(timeVl) {
if (timeVl) {
this.value2 = [timeVl[0].getTime(),timeVl[1].getTime()]
}
!this.value2 && (this.value2 = '')
this.handleChange()
},
},
}
</script>
<style lang="less" scoped>
.form-search-container__item {
flex: 0 0 30%;
}
.el-range-editor.el-input__inner {
width: 245px;
}
.form-search-container__item /deep/ .el-form-item__content {
width: 242px;
}
.form-search-container__item /deep/ .el-form-item__content .el-select {
width: 100%;
}
.el-form-item {
margin-bottom: 10px;
display: flex;
}
</style>
4、表格
- 排序:数字、时间类型增加排序、其他删除排序
- 对齐:数字类居中对齐、文字类左对齐
- 添加斑马纹
- 表格图片统一
- 统一处理宽高:60px
- 表格列居中显示
- 无图片时,统一文案为【无图片】
- 图片加载失败时统一处理为自定义图片
- 带跳转增加亮色样式
- 不同状态前面增加标记
<el-badge is-dot class="item"></el-badge>
,左对齐 - 表格增加全屏功能
- 表格新增按钮
- 操作类先【新增类按钮】后【删除类按钮】;新增类按钮为主题色,删除类按钮为红色
- 表格操作按钮统一
- 按钮列表头文案统一为【操作】
- 操作列固定在表格右侧
- 统一使用文字按钮
<el-button type="text">文字按钮</el-button>
- 按钮之间加垂直分隔线 <el-divider direction="vertical">
- 当按钮<=3个时,直接显示
- 当按钮>3时,第一、二个按钮一般为【编辑】或者【详情】或者【删除】,其他归纳到【更多】按钮里
- 文案统一为【编辑】【删除】【更多】;部分编辑按钮文档现在为修改
- 当鼠标放到【更多按钮】或者【点击更多按钮】时,出现下拉菜单 <el-dropdown-menu slot="dropdown">
- 一般按钮使用主题色,删除按钮红色- 没有权限操作时展示置灰,(还是显示原来颜色,只是置灰),目前都是不展示,不友好
- <el-link type="info" disabled>信息链接
写在最后
以上只是我实践过的部分经验,当然还有很多方面
UI&交互规范,往往容易被忽视,但随着页面、场景增多;无规范会使得系统难用,经过这次UI&交互统一,其实成本挺大,收益也很不好衡量,最好还是一开始就制定规范
转载自:https://juejin.cn/post/7081635333787516964