最近公司內部的工時記錄系統需要改版,主要是工時錄入頁面的更改,原先錄入頁面一次只能錄一條記錄,現在改為可以一次錄入多條。
我原以為這個專案只是個小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動畫也沒辦法覆蓋這一段空擋,希望有了解的大佬能不吝賜教。