記一次工時系統改版(前端下拉菜單懶載入與選項數據回顯)

最近公司內部的工時記錄系統需要改版,主要是工時錄入頁面的更改,原先錄入頁面一次只能錄一條記錄,現在改為可以一次錄入多條。

我原以為這個項目只是個小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動畫也沒辦法覆蓋這一段空擋,希望有了解的大佬能不吝賜教。