记一次工时系统改版(前端下拉菜单懒加载与选项数据回显)

最近公司内部的工时记录系统需要改版,主要是工时录入页面的更改,原先录入页面一次只能录一条记录,现在改为可以一次录入多条。

我原以为这个项目只是个小case,但是实际做下来还是感觉有点难度的,并且在项目过程中也学到了一些新知识,所以我决定把整个过程记录下来。

具体需求:

  1. 用户选择左上角的日期,然后点击确定,在表格中按照所选择的日期,每一天生成一条记录,日期只能选过去的30天到未来的7天
  2. 当表格记录生成后,如果某些日期已经填写过工时记录,需要把这些记录回显出来,供用户查看、更改
  3. 在表格中日期这一栏后面有个小按钮,点击之后可以再生成同一天的一条记录,然后原有记录的小按钮变为不可点,新记录也有一个小按钮,点击之后删除这条新生成的记录
  4. 表格中项目这一栏是个下拉菜单,点选项目之后,要把后面相应的城市,类型,负责人带出来
  5. 城市,类型,负责人也是下拉菜单,如果所选的项目有相关的信息,那么这三个下拉菜单不可选,如果选择的项目没有包含对应的城市,类型,负责人信息,可以让用户通过下拉菜单进行选择
  6. 工时这一栏只能选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动画也没办法覆盖这一段空挡,希望有了解的大佬能不吝赐教。