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

KlllB發表於2021-04-25

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

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

相關文章