记一次工时系统改版(前端下拉菜单懒加载与选项数据回显)
最近公司内部的工时记录系统需要改版,主要是工时录入页面的更改,原先录入页面一次只能录一条记录,现在改为可以一次录入多条。
我原以为这个项目只是个小case,但是实际做下来还是感觉有点难度的,并且在项目过程中也学到了一些新知识,所以我决定把整个过程记录下来。
具体需求:
- 用户选择左上角的日期,然后点击确定,在表格中按照所选择的日期,每一天生成一条记录,日期只能选过去的30天到未来的7天
- 当表格记录生成后,如果某些日期已经填写过工时记录,需要把这些记录回显出来,供用户查看、更改
- 在表格中日期这一栏后面有个小按钮,点击之后可以再生成同一天的一条记录,然后原有记录的小按钮变为不可点,新记录也有一个小按钮,点击之后删除这条新生成的记录
- 表格中项目这一栏是个下拉菜单,点选项目之后,要把后面相应的城市,类型,负责人带出来
- 城市,类型,负责人也是下拉菜单,如果所选的项目有相关的信息,那么这三个下拉菜单不可选,如果选择的项目没有包含对应的城市,类型,负责人信息,可以让用户通过下拉菜单进行选择
- 工时这一栏只能选0.5天或者1天
示意图如下:
项目采用的UI组件是Element UI。
首先是做日期组件的选择限制,直接用组件自带的pickerOption进行限制,开始时间是当前时间的毫秒数+60*60*24*1000*30,结束时间是当前时间的毫秒数+60*60*24*1000*7,如果传进来的时间小于开始时间或者大于结束时间,return true。
接下来是生成表格中每一行的工时记录,并且进行数据回显,这个部分是最麻烦的部分。首先是生成工时记录,点击确定之后,将开始时间和结束时间转为日期对象,然后使用while循环,循环条件是开始时间小于结束时间,每次循环向表格数据列表push一条记录对象,对象包含一些基本信息,然后对开始时间加60 * 60 * 24 * 1000毫秒。
然后以开始时间和结束时间为参数向后台请求这期间的工时记录。第一个问题是,之前生成的表格数据列表默认每一天只有一条记录,但是之前填写过的记录中可能一天有2条,现在需要把历史记录中的数据合并到表格数据中。具体方法是循环表格数据,在每次循环中把历史记录中日期与当前记录匹配的筛选出来组成数组,判断数组长度,如果数组长度是1,就直接进行属性的替换与设置,如果数组长度是2,先进行属性设置与替换,再向表格数组插入一条记录。每一项记录都需要判断是否有城市,类型,负责人的数据,给这个字段分别设置一个flag,用以控制对应下拉菜单是否可以操作,然后根据每一天有一条还是2条记录来设置一个falg,用以操控日期后面的小按钮的样式与行为。然后对表格数据进行排序,否则同一天的2条记录会显示在不同的地方。
然后是对应数据的回显,我一开始想的很简单,直接在created里面请求项目,城市,类型,负责人的数据,然后绑定到对应的下拉菜单组件就行了,但是没有想到项目的数据有一千多条,负责人的数据也有三百多条,虽然数据看起来不大,但是实际操作中发现,由于要绑定这些数据,表格行生成的速度很慢,选一周的日期,对应记录的生成就要十几秒,更别说再加上历史记录回显了。
百度搜索了一下加载慢的问题,好几篇文章都提到了使用指令进行懒加载,一开始只加载10条选项,然后监听滚动事件,滚动一次增加若干条。我也采用了这个方案,但是增加了visible-change事件触发后,将选项重置的操作,以避免用户多次将选项加载的比较多,导致页面变卡的问题。接下来遇到一个致命的问题,因为一开始只加载了10条选项,工时记录中的项目或者负责人很多时候都不在这10条记录之中,没有办法正确回显。我有考虑过把每一条记录的选项维护在对应记录的对象中,在回显数据的时候,如果对应的数据不在一开始加载的10条选项中,就把这条记录加进去,但是这样一来每一条记录的对象又会变得很臃肿。这些共用选项又会在每一条记录中都存储一遍。
既然选项已经独立出来维护了,那么需要回显的记录也可以独立出来维护,最终的备选项就是10条记录 + 所有的回显项数组。把之前生成工时记录数据后面再执行一个函数。具体逻辑是,循环表格数据,在每次循环中判断当前记录是否有项目id或者负责人id,如果有,就在备选列表和回显项数组中进行查找,如果备选列表和回显项数组中都没有,就把这一项加入回显项数组,当用户往下拉选项,如果出现了对应的选项,就需要把回显项数组中的这一项去除,判断逻辑是如果备选列表和回显项数组中都有,就把回显项数组中的这一项删除。这个函数在备选项变化时也需要被调用。
另一个问题是备选项总是不能完全显示所有选项,所以当用户搜索选项的时候,很有可能会搜不到应该有选项,这只要再添加一个筛选函数就行了。
主要的功能大概就是这些,具体代码如下
vue文件
1 <template> 2 <div class="new-task-container"> 3 <!-- 日期范围选择 --> 4 <el-date-picker 5 clearable 6 v-model="dateRange" 7 type="daterange" 8 range-separator="至" 9 start-placeholder="开始日期" 10 end-placeholder="结束日期" 11 value-format="yyyy-MM-dd" 12 :picker-options="pickerOptions" 13 > 14 </el-date-picker> 15 <el-button 16 type="primary" 17 class="submit-btn1" 18 :disabled="!dateRange || dateRange.length != 2" 19 @click="generateBasicTaskData" 20 >确定</el-button> 21 22 <el-button 23 type="success" 24 class="submit-btn2" 25 :disabled="tableData.length == 0" 26 @click="addTask" 27 >提交</el-button> 28 29 <!-- 工时表格 --> 30 <el-table 31 :data="tableData" 32 border 33 style="width: 100%" 34 height="54rem" 35 row-class-name="table-row" 36 > 37 <el-table-column 38 type="index" 39 label="序号" 40 align="center" 41 > 42 </el-table-column> 43 <el-table-column 44 prop="date" 45 label="日期" 46 align="center" 47 class-name="date-style" 48 > 49 <template slot-scope="scope"> 50 <div class="date-style"> 51 <span>{{scope.row.date}}</span> 52 <i 53 class="el-icon-circle-plus-outline" 54 :class="scope.row.hasSibling || scope.row.time==1?'disable-add-sibling':''" 55 @click="addSibling(scope.$index)" 56 v-if="!scope.row.isSibling" 57 ></i> 58 59 <i 60 class="el-icon-remove-outline" 61 v-if="scope.row.isSibling" 62 @click="removeSibling(scope.$index)" 63 ></i> 64 65 </div> 66 </template> 67 </el-table-column> 68 <el-table-column 69 prop="projectName" 70 label="项目" 71 align="center" 72 width="500" 73 > 74 <template slot-scope="scope"> 75 <el-select 76 v-model="scope.row.projectID" 77 placeholder="输入项目名称进行搜索" 78 style="width:100%" 79 @change="projectChange(scope.row.projectID,scope.$index)" 80 filterable 81 :filter-method="projectFilter" 82 v-el-select-loadmore="loadMoreProject" 83 @visible-change="projectFilter" 84 > 85 <el-option 86 v-for="item in bindingProjects.concat(echoProjects)" 87 :key="item.uuid" 88 :label="item.label" 89 :value="item.uuid" 90 > 91 </el-option> 92 </el-select> 93 </template> 94 </el-table-column> 95 <el-table-column 96 prop="city" 97 label="城市" 98 align="center" 99 > 100 <template slot-scope="scope"> 101 102 <el-cascader 103 clearable 104 filterable 105 style="width: 120px;" 106 :show-all-levels="false" 107 :options="areas" 108 v-model="scope.row.city" 109 :disabled="scope.row.cityEnabled" 110 ></el-cascader> 111 </template> 112 </el-table-column> 113 <el-table-column 114 prop="type" 115 label="类型" 116 align="center" 117 > 118 <template slot-scope="scope"> 119 <el-select 120 v-model="scope.row.type" 121 placeholder="请选择" 122 style="width:100%" 123 filterable 124 :disabled="scope.row.typeEnabled" 125 > 126 <el-option 127 v-for="item in projectTypes" 128 :key="item.id" 129 :label="item.name" 130 :value="item.id" 131 > 132 </el-option> 133 </el-select> 134 </template> 135 </el-table-column> 136 <el-table-column 137 prop="leader" 138 label="负责人" 139 align="center" 140 > 141 <template slot-scope="scope"> 142 <el-select 143 v-model="scope.row.leader" 144 placeholder="输入负责人姓名进行搜索" 145 style="width:100%" 146 filterable 147 :disabled="scope.row.leaderEnabled" 148 :filter-method="userFilter" 149 v-el-select-loadmore="loadMoreLeader" 150 @visible-change="userFilter" 151 @change="generateEchoUsers" 152 > 153 <el-option 154 v-for="item in bindingUsers.concat(echoUsers)" 155 :key="item.id" 156 :label="item.name" 157 :value="item.id" 158 > 159 </el-option> 160 </el-select> 161 </template> 162 </el-table-column> 163 <el-table-column 164 prop="time" 165 label="工时" 166 align="center" 167 > 168 <template slot-scope="scope"> 169 <el-input-number 170 v-model="scope.row.time" 171 controls-position="right" 172 :min="0.5" 173 :max="1" 174 :step="0.5" 175 :disabled="scope.row.hasSibling || scope.row.isSibling" 176 ></el-input-number> 177 </template> 178 179 </el-table-column> 180 <el-table-column 181 prop="remark" 182 label="备注" 183 > 184 <template slot-scope="scope"> 185 <el-input 186 v-model="scope.row.remark" 187 placeholder="请输入内容" 188 ></el-input> 189 </template> 190 </el-table-column> 191 </el-table> 192 193 </div> 194 </template> 195 196 <script> 197 import newTask from "./js/newTask" 198 export default { 199 ...newTask 200 } 201 </script> 202 203 <style lang="scss" scoped> 204 @import "./style/newTask"; 205 </style>
js文件
1 export default { 2 data() { 3 return { 4 // 日期 5 dateRange: [], 6 // 工时表格数据 7 tableData: [], 8 // 日期限制,参数是当前日期,可选返回true,不可选返回false 9 // 只能选过去的30天,到未来的7天 10 pickerOptions: { 11 disabledDate(time) { 12 let startDay = new Date(Date.now() - 60 * 60 * 24 * 1000 * 30) 13 let endDay = new Date(Date.now() + 60 * 60 * 24 * 1000 * 7) 14 if (time < startDay || time > endDay) return true 15 return false 16 }, 17 }, 18 // 项目列表 19 projects: [], 20 // 城市列表 21 areas: [], 22 // 项目类型列表 23 projectTypes: [], 24 // 用户列表 25 userList: [], 26 // 加载的用户列表 27 bindingUsers: [], 28 // 加载的项目列表 29 bindingProjects: [], 30 // 需要回显的项目列表 31 echoProjects: [], 32 // 需要会先的负责人列表 33 echoUsers: [], 34 } 35 }, 36 created() { 37 this.getProjects() 38 this.getCitys() 39 this.getProjectTypes() 40 this.getUsers() 41 }, 42 43 directives: { 44 // 在指令挂载和更新时自动执行 45 "el-select-loadmore": { 46 bind(el, binding) { 47 // 获取element-ui定义好的scroll盒子 48 const SELECTWRAP_DOM = el.querySelector(".el-select-dropdown .el-select-dropdown__wrap") 49 if (SELECTWRAP_DOM) { 50 SELECTWRAP_DOM.addEventListener("scroll", function() { 51 /** 52 * scrollHeight 获取元素内容高度(只读) 53 * scrollTop 获取或者设置元素的偏移值, 54 * 常用于:计算滚动条的位置, 当一个元素的容器没有产生垂直方向的滚动条, 那它的scrollTop的值默认为0. 55 * clientHeight 读取元素的可见高度(只读) 56 * 如果元素滚动到底, 下面等式返回true, 没有则返回false: 57 * ele.scrollHeight - ele.scrollTop === ele.clientHeight; 58 */ 59 const condition = this.scrollHeight - this.scrollTop <= this.clientHeight 60 // binding.value 是指令绑定的值 即函数loadMore 61 if (condition) binding.value() 62 }) 63 } 64 }, 65 }, 66 }, 67 watch: { 68 bindingProjects(newVal) { 69 newVal.forEach((project) => { 70 this.generateEchoProjects(project.uuid) 71 }) 72 }, 73 bindingUsers(newVal) { 74 newVal.forEach((user) => { 75 this.generateEchoUsers(user.id) 76 }) 77 }, 78 }, 79 methods: { 80 loadMoreProject() { 81 // elementui下拉超过7条才会出滚动条,如果初始不出滚动条无法触发loadMore方法 82 // 每次滚动到底部可以新增条数 83 this.bindingProjects = this.projects.slice(0, this.bindingProjects.length + 5) 84 }, 85 loadMoreLeader() { 86 // elementui下拉超过7条才会出滚动条,如果初始不出滚动条无法触发loadMore方法 87 // 每次滚动到底部可以新增条数 88 this.bindingUsers = this.userList.slice(0, this.bindingUsers.length + 5) 89 }, 90 91 // 项目筛选函数 92 projectFilter(val) { 93 if (val && typeof val == "string") { 94 this.bindingProjects = this.projects.filter((project) => project.label.includes(val)) 95 } else { 96 this.bindingProjects = this.projects.slice(0, 10) 97 } 98 }, 99 100 // 负责人筛选函数 101 userFilter(val) { 102 if (val && val == "string") { 103 this.bindingUsers = this.userList.filter((user) => user.name.includes(val)) 104 } else { 105 this.bindingUsers = this.userList.slice(0, 10) 106 } 107 }, 108 109 // 根据日期生成基本的工时信息,只能选过去的30天,到未来的7天 110 generateBasicTaskData() { 111 // 日期对象拷贝,防止影响日期选择的数据 112 this.tableData = [] 113 let startDay = new Date(this.dateRange[0]) 114 let endDay = new Date(this.dateRange[1]) 115 116 while (startDay <= endDay) { 117 let baseObj = { 118 date: "", 119 projectID: "", 120 city: [], 121 type: "", 122 leader: "", 123 time: "", 124 remark: "", 125 hasSibling: false, 126 cityEnabled: false, 127 typeEnabled: false, 128 leaderEnabled: false, 129 num: "", 130 } 131 baseObj.date = this.$utils.formatTime(startDay, "yyyy-MM-dd") 132 this.tableData.push(baseObj) 133 startDay = new Date(startDay.getTime() + 60 * 60 * 24 * 1000) 134 } 135 136 let params = { 137 page: 1, 138 size: 80, 139 properties: "createTime", 140 direction: "desc", 141 condition: { 142 beginTime: this.dateRange[0], 143 endTime: this.dateRange[1], 144 }, 145 } 146 147 // 查询选中的日期范围的工时记录 148 // 遍历tableData,从工时记录中找出匹配日期的记录组成数组(同一天可能有2条记录) 149 this.$request.getTaskHistoryList(params).then((response) => { 150 if (!response.rows) return 151 let rows = response.rows 152 this.tableData.forEach((task) => { 153 let taskRecords = rows.filter((record) => record.date == task.date) 154 switch (taskRecords.length) { 155 case 1: 156 this.setTaskInfo(task, taskRecords[0]) 157 break 158 case 2: 159 this.setTaskInfo(task, taskRecords[0]) 160 this.$set(task, "hasSibling", true) 161 let baseObj = { 162 date: task.date, 163 isSibling: true, 164 } 165 baseObj = this.setTaskInfo(baseObj, taskRecords[1]) 166 this.tableData.push(baseObj) 167 break 168 default: 169 break 170 } 171 }) 172 173 // 排序规则:按日期从小到大排,如果日期一致,按项目号从小到大排 174 this.tableData.sort((a, b) => { 175 if (a.date == b.date) { 176 let numA = a.num.replaceAll("-", "") 177 let numB = b.num.replaceAll("-", "") 178 return Number(numA) - Number(numB) 179 } 180 return new Date(a.date) - new Date(b.date) 181 }) 182 this.tableData.forEach((task) => { 183 if (task.projectID) { 184 this.generateEchoProjects(task.projectID) 185 } 186 if (task.leader) { 187 this.generateEchoUsers(task.leader) 188 } 189 }) 190 }) 191 }, 192 193 // 生成需要回显的项目列表 194 generateEchoProjects(projectID) { 195 let indexInBindingProjects = this.bindingProjects.findIndex((project) => project.uuid == projectID) 196 let indexInEchoProjects = this.echoProjects.findIndex((project) => project.uuid == projectID) 197 let project = this.projects.find((project) => project.uuid == projectID) 198 if (indexInBindingProjects == -1 && indexInEchoProjects == -1) { 199 this.echoProjects.push(project) 200 } 201 if (indexInBindingProjects != -1 && indexInEchoProjects != -1) { 202 this.echoProjects.splice(indexInEchoProjects, 1) 203 } 204 }, 205 206 // 生成需要回显的负责人列表 207 generateEchoUsers(userID) { 208 let indexInBindingUsers = this.bindingUsers.findIndex((user) => user.id == userID) 209 let indexInEchoUsers = this.echoUsers.findIndex((user) => user.id == userID) 210 let user = this.userList.find((user) => user.id == userID) 211 if (indexInBindingUsers == -1 && indexInEchoUsers == -1) { 212 this.echoUsers.push(user) 213 } 214 if (indexInBindingUsers != -1 && indexInEchoUsers != -1) { 215 this.echoUsers.splice(indexInEchoUsers, 1) 216 } 217 }, 218 219 // 设置工时记录回显信息 220 setTaskInfo(task, obj) { 221 this.$set(task, "projectID", obj.project.id) 222 this.$set(task, "city", obj.cityId.split(",")) 223 this.$set(task, "type", obj.projectTypeId) 224 this.$set(task, "leader", obj.officerUserId) 225 this.$set(task, "time", Number(obj.hour)) 226 this.$set(task, "remark", obj.remark) 227 this.$set(task, "taskId", obj.id) 228 this.$set(task, "num", obj.num) 229 if (task.city.length > 2) { 230 this.$set(task, "cityEnabled", true) 231 } 232 if (task.type) { 233 this.$set(task, "typeEnabled", true) 234 } 235 if (task.leader) { 236 this.$set(task, "leaderEnabled", true) 237 } 238 return task 239 }, 240 241 // 同一天再增加一项工时记录 242 addSibling(index) { 243 if (this.tableData[index].hasSibling) return 244 if (this.tableData[index].time == 1) return 245 let baseObj = { 246 date: this.tableData[index].date, 247 projectID: "", 248 city: [], 249 type: "", 250 leader: "", 251 time: "", 252 remark: "", 253 isSibling: true, 254 cityEnabled: false, 255 typeEnabled: false, 256 leaderEnabled: false, 257 num: "", 258 } 259 this.tableData[index].hasSibling = true 260 this.tableData.splice(index + 1, 0, baseObj) 261 }, 262 263 // 移除同一天新增的工时记录 264 removeSibling(index) { 265 let date = this.tableData[index].date 266 let rows = this.tableData.filter((task) => task.date == date) 267 if (rows.length == 2) { 268 this.tableData.splice(index, 1) 269 let sibling = this.tableData.find((task) => task.date == date) 270 if (sibling.hasSibling) { 271 sibling.hasSibling = false 272 } 273 } 274 }, 275 // 获取所有的项目信息 276 getProjects() { 277 this.$request.getProjectList().then((response) => { 278 this.projects = response.map((project) => { 279 project.label = project.num + " - " + project.name 280 return project 281 }) 282 this.projectFilter() 283 }) 284 }, 285 // 获取所有的城市信息 286 getCitys() { 287 this.$request.myAreas().then((response) => { 288 this.areas = response 289 }) 290 }, 291 // 获取所有的项目类型信息 292 getProjectTypes() { 293 this.$request.getTypeList().then((response) => { 294 this.projectTypes = response 295 }) 296 }, 297 // 获取所有的项目类型信息 298 getUsers() { 299 this.$request.getUserList().then((response) => { 300 this.userList = response 301 this.userFilter() 302 }) 303 }, 304 // 查询单个项目的相关信息 305 projectChange(projectID, rowIndex) { 306 this.$request.getProjectInfoByID(projectID).then((response) => { 307 if (response.listArea[0]) { 308 let rawString = response.listArea[0].parentIds.replaceAll("[", "") 309 rawString = rawString.replaceAll("]", "") 310 let cityArr = rawString + "," + response.listArea[0].uuid 311 let city = cityArr.split(",") 312 city.splice(0, 1) 313 this.$set(this.tableData[rowIndex], "city", city) 314 this.tableData[rowIndex].cityEnabled = true 315 } 316 if (response.listProjectType[0]) { 317 this.$set(this.tableData[rowIndex], "type", response.listProjectType[0].uuid) 318 this.tableData[rowIndex].typeEnabled = true 319 } 320 if (response.listPrincipalUserId[0]) { 321 this.$set(this.tableData[rowIndex], "leader", response.listPrincipalUserId[0].uuid) 322 this.generateEchoUsers(response.listPrincipalUserId[0].uuid) 323 this.tableData[rowIndex].leaderEnabled = true 324 } 325 }) 326 }, 327 // 新增工时 328 addTask() { 329 if (this.tableData.length == 0) { 330 this.$message.error("请填写工时信息") 331 } 332 let hasEmpty = this.tableData.some((task) => !task.projectID) 333 if (hasEmpty) { 334 this.$message.error("请填写完整工时信息") 335 return 336 } 337 let params = this.tableData.map((item) => { 338 let task = { 339 projectId: item.projectID, 340 date: item.date, 341 hour: item.time, 342 remark: item.remark, 343 cityId: item.city.join(","), 344 officerUserId: item.leader, 345 projectTypeId: item.type, 346 } 347 if (item.taskId) task.uuid = item.taskId 348 return task 349 }) 350 this.$request.addTask(params).then((response) => { 351 console.log(response) 352 if (response.code == 200) { 353 this.$message.success("工时录入成功") 354 this.tableData = [] 355 this.dateRange = [] 356 } else { 357 this.$message.error(response.message) 358 } 359 }) 360 }, 361 }, 362 }
最后还有一个问题没能解决:点击确定生成表格数据,如果选择的天数过多,那么从点击按钮到表格数据显示会需要几秒钟,我原本以为任然是数据加载的问题,但是实际测试后发现任然是数据绑定的问题,所以使用loading动画也没办法覆盖这一段空挡,希望有了解的大佬能不吝赐教。