edit.vue 75 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772
  1. <template>
  2. <el-dialog
  3. v-loading="loading"
  4. :visible.sync="dialogVisible"
  5. :close-on-click-modal="false"
  6. :close-on-press-escape="false"
  7. :show-close="false"
  8. append-to-body
  9. fullscreen
  10. class="dialog schedule-edit-dialog"
  11. top="0"
  12. @scroll="handleScroll"
  13. @open="loadData"
  14. @close="closeDialog"
  15. >
  16. <div slot="title" class="edit-dialog-header">
  17. <div class="title">{{ title }}</div>
  18. <div class="operate">
  19. <template v-for="btn in toolbars">
  20. <el-button
  21. v-if="btn.show && (!btn.steps || btn.steps.includes(activeStep)) "
  22. :key="btn.key"
  23. :type="btn.type"
  24. :icon="btn.icon"
  25. :size="btn.size || 'mini'"
  26. @click="handleAction(btn.key)"
  27. >
  28. {{ btn.key ==='undo'? undoButtonLabel : btn.label }}
  29. </el-button>
  30. </template>
  31. </div>
  32. </div>
  33. <el-steps :active="activeStep" finish-status="success" simple style="margin: 20px 0;">
  34. <el-step title="基础信息配置" />
  35. <el-step title="人员排班" />
  36. <el-step title="排班总览" />
  37. </el-steps>
  38. <el-form
  39. v-if="activeStep === 1"
  40. ref="form"
  41. :label-width="formLabelWidth"
  42. label-position="left"
  43. :model="formData"
  44. :rules="rules"
  45. class="schedule-form"
  46. @submit.native.prevent
  47. >
  48. <el-row :gutter="20" class="form-row">
  49. <el-col :span="12">
  50. <el-form-item label="选择历史排班" prop="oldScheduleId" :show-message="false">
  51. <el-select
  52. v-model="formData.oldScheduleId"
  53. :disabled="readonly"
  54. filterable
  55. clearable
  56. :placeholder=" readonly ? '' : '请选择历史排班'"
  57. @change="handleScheduleChange"
  58. >
  59. <el-option
  60. v-for="item in scheduleOptions"
  61. :key="item.id"
  62. :label="item.title"
  63. :value="item.id"
  64. />
  65. </el-select>
  66. </el-form-item>
  67. </el-col>
  68. <el-col :span="12">
  69. <el-form-item label="排班名称" prop="title" required :show-message="false">
  70. <el-input
  71. v-model="formData.title"
  72. type="text"
  73. clearable
  74. show-word-limit
  75. :maxlength="64"
  76. :disabled="readonly"
  77. placeholder="请输入排班名称"
  78. />
  79. </el-form-item>
  80. </el-col>
  81. </el-row>
  82. <el-row :gutter="20" class="form-row">
  83. <el-col :span="12">
  84. <el-form-item prop="dateRange" required :show-message="false">
  85. <template slot="label">
  86. 排班时间
  87. <el-tooltip effect="dark" content="最多可支持45天内的排班" placement="top">
  88. <i class="el-icon-question question-icon" />
  89. </el-tooltip>
  90. </template>
  91. <el-date-picker
  92. v-model="formData.dateRange"
  93. :disabled="readonly"
  94. type="daterange"
  95. range-separator="至"
  96. start-placeholder="开始日期"
  97. end-placeholder="结束日期"
  98. value-format="yyyy-MM-dd"
  99. unlink-panels
  100. :picker-options="pickerOptions"
  101. @change="handleDateChange"
  102. />
  103. </el-form-item>
  104. </el-col>
  105. <el-col :span="12">
  106. <el-form-item label="排班配置" prop="config" required :show-message="false">
  107. <el-select
  108. v-model="formData.config"
  109. :disabled="readonly"
  110. filterable
  111. placeholder="请选择排班配置"
  112. @change="handleConfigChange"
  113. >
  114. <el-option-group
  115. v-for="group in configOptions"
  116. :key="group.label"
  117. :label="group.label"
  118. >
  119. <el-option
  120. v-for="item in group.options"
  121. :key="item.value"
  122. :label="item.label"
  123. :value="item.value"
  124. :disabled="item.isEffective === 'N'"
  125. />
  126. </el-option-group>
  127. </el-select>
  128. </el-form-item>
  129. </el-col>
  130. </el-row>
  131. <el-form-item label="排班人员" prop="scheduleStaff" required :show-message="false">
  132. <el-select
  133. v-model="formData.scheduleStaff"
  134. :disabled="readonly"
  135. multiple
  136. filterable
  137. placeholder="请选择排班人员"
  138. >
  139. <el-option
  140. v-for="item in userList"
  141. :key="item.userId"
  142. :label="item.userName"
  143. :value="item.userId"
  144. />
  145. </el-select>
  146. </el-form-item>
  147. <el-row :gutter="20" class="form-row">
  148. <el-col :span="24">
  149. <el-form-item label="调班审批人" prop="approver" :show-message="false">
  150. <el-select
  151. v-model="formData.approver"
  152. :disabled="readonly"
  153. multiple
  154. filterable
  155. placeholder="请选择"
  156. >
  157. <el-option
  158. v-for="item in userList"
  159. :key="item.userId"
  160. :label="item.userName"
  161. :value="item.userId"
  162. />
  163. </el-select>
  164. </el-form-item>
  165. </el-col>
  166. </el-row>
  167. <el-table
  168. ref="scheduleTable"
  169. :data="formData.scheduleShift"
  170. border
  171. stripe
  172. highlight-current-row
  173. style="width: 100%"
  174. :max-height="maxHeight"
  175. class="schedule-table"
  176. >
  177. <el-table-column type="index" label="序号" width="50" header-align="center" align="center" />
  178. <el-table-column
  179. v-for="(item, fIndex) in scheduleColumn"
  180. :key="fIndex"
  181. :prop="item.key"
  182. :label="item.label"
  183. :width="item.width"
  184. :min-width="item.minWidth"
  185. header-align="center"
  186. align="center"
  187. >
  188. <template slot-scope="scope">
  189. <template v-if="item.key === 'dateRange'">
  190. <div v-for="(d, di) in scope.row.dateRange" :key="di">
  191. {{ d.type === 'allday' ? '全天' : (`当天 ${d.startTime}` + ' 至 ' + `${d.isSecondDay === 'Y' ? '第二天' : '当天'} ${d.endTime}`) }}
  192. </div>
  193. </template>
  194. <div v-else-if="item.key === 'positions'">
  195. <el-tag
  196. v-for="(p, pi) in scope.row.positions"
  197. :key="pi"
  198. type="primary"
  199. style="margin-left: 5px;"
  200. >{{ p }}</el-tag>
  201. </div>
  202. <div v-else-if="item.key === 'isEnabled'">{{ scope.row.isEnabled === 'Y' ? '是' : '否' }}</div>
  203. <div v-else-if="item.key === 'color'">
  204. <div class="color-item" :style="`background-color: ${scope.row.color}; width: 20px; height: 20px; margin: 0 auto; border-radius: 2px;`" />
  205. </div>
  206. <div v-else>
  207. <span>{{ scope.row[item.key] }}</span>
  208. </div>
  209. </template>
  210. </el-table-column>
  211. </el-table>
  212. </el-form>
  213. <div v-if="activeStep === 2" ref="scheduleContainer" class="schedule-container">
  214. <div class="shift-legend">
  215. <div class="legend-title">班次说明:</div>
  216. <div class="legend-items">
  217. <div
  218. v-for="(shift, index) in formData.scheduleShift"
  219. :key="index"
  220. class="legend-item"
  221. >
  222. <div
  223. class="color-block"
  224. :style="{ backgroundColor: shift.color }"
  225. ></div>
  226. <span class="shift-name">{{ shift.name }}</span>
  227. </div>
  228. </div>
  229. </div>
  230. <div ref="schedule" class="schedule-box">
  231. <div ref="scrollTarget" class="abscissa" :style="{ left: leftOffset + 'px' }">
  232. <div v-for="(month, mIndex) in Object.keys(dateObj)" :key="mIndex" class="abs-type">
  233. <div class="month">{{ month }}</div>
  234. <div class="abscissa-item">
  235. <div
  236. v-for="(date, dIndex) in dateObj[month]"
  237. :ref="`day${dIndex}`"
  238. :key="dIndex"
  239. class="dateweek"
  240. @click="!readonly && showShiftSetting($event, date)"
  241. >
  242. <div class="date">{{ date.split('-')[2] }}</div>
  243. <div class="week" :class="{ 'weekend-bg': getDayOfWeek(date).isWeekend }">{{ getDayOfWeek(date).text }}</div>
  244. </div>
  245. </div>
  246. </div>
  247. </div>
  248. <el-popover
  249. ref="popover"
  250. trigger="manual"
  251. placement="bottom"
  252. width="255"
  253. title=""
  254. :reference="popoverReference"
  255. >
  256. <el-form
  257. ref="popover-form"
  258. label-width="80px"
  259. label-position="right"
  260. :model="shiftForm"
  261. size="mini"
  262. class="popover-form"
  263. @submit.native.prevent
  264. >
  265. <el-form-item label="选择操作">
  266. <el-radio-group v-model="shiftForm.operateType">
  267. <el-tooltip effect="dark" content="将该列排班数据复制到指定的一个或多个日期" placement="left">
  268. <el-radio-button label="copy">复制</el-radio-button>
  269. </el-tooltip>
  270. <el-tooltip effect="dark" content="将该列排班数据按指定间隔向后覆盖" placement="right">
  271. <el-radio-button label="cycle">循环</el-radio-button>
  272. </el-tooltip>
  273. </el-radio-group>
  274. </el-form-item>
  275. <!-- <el-form-item label="覆盖方式">
  276. <el-radio-group v-model="shiftForm.pasteType">
  277. <el-tooltip effect="dark" content="无视已有数据,完全覆盖" placement="top">
  278. <el-radio-button label="all">全覆盖</el-radio-button>
  279. </el-tooltip>
  280. <el-tooltip effect="dark" content="仅覆盖目标列的空值" placement="top">
  281. <el-radio-button label="empty">空值覆盖</el-radio-button>
  282. </el-tooltip>
  283. </el-radio-group>
  284. </el-form-item> -->
  285. <el-form-item v-if="shiftForm.operateType === 'copy'" label="覆盖日期">
  286. <el-select
  287. v-model="shiftForm.dates"
  288. filterable
  289. width="100%"
  290. clearable
  291. multiple
  292. collapse-tags
  293. :multiple-limit="10"
  294. placeholder="请选择需覆盖的日期"
  295. >
  296. <el-option
  297. v-for="(item, index) in dateList"
  298. :key="index"
  299. :label="item"
  300. :value="item"
  301. />
  302. </el-select>
  303. </el-form-item>
  304. <el-form-item v-else label="覆盖间隔">
  305. <el-input-number
  306. v-model="shiftForm.cycle"
  307. type="number"
  308. :min="0"
  309. :max="10"
  310. />
  311. </el-form-item>
  312. <div style="text-align: right; margin: 0">
  313. <el-tooltip effect="dark" content="仅覆盖目标列的空值" placement="bottom">
  314. <el-button type="primary" size="mini" plain @click="handleShiftSetting('empty')">覆盖空值</el-button>
  315. </el-tooltip>
  316. <el-tooltip effect="dark" content="无视目标列已有数据,完全覆盖" placement="bottom">
  317. <el-button type="warning" size="mini" plain @click="handleShiftSetting('all')">覆盖全部</el-button>
  318. </el-tooltip>
  319. <el-button size="mini" plain @click="resetShiftSetting">取消</el-button>
  320. </div>
  321. </el-form>
  322. </el-popover>
  323. <div ref="scheduleContent" class="schedule-content">
  324. <div ref="sidebar" class="ordinate" :style="{ top: topOffset + 'px' }">
  325. <div
  326. v-for="item in ordinateList"
  327. :key="item.value"
  328. class="ordinate-item"
  329. :style="{ color: viewType === 'users' ? '#606266' : `${item.color}`}"
  330. >
  331. {{ item.label }}
  332. </div>
  333. </div>
  334. <div class="shift-content">
  335. <div v-for="(row, rIndex) in ordinateList" :key="row.value" class="shift-row">
  336. <!-- <div v-for="(column, cIndex) in dateList" :key="cIndex" class="shift-column"> -->
  337. <!-- @contextmenu.prevent="handleRightClick($event, {row, rIndex, column, cIndex})" -->
  338. <div
  339. v-for="(column, cIndex) in dateList"
  340. :key="cIndex"
  341. ref="shiftItem"
  342. class="shift-item"
  343. :style="{ display: viewType === 'users' ? 'grid' : 'flex'}"
  344. @mouseenter=" hoveredIndex = !readonly && `${row.value}-${cIndex}`"
  345. @mouseleave=" hoveredIndex = !readonly && null"
  346. @click.prevent="!readonly && handleShiftClick($event, {row, rIndex, column, cIndex})"
  347. >
  348. <div
  349. v-for="(shift, sIndex) in scheduleData[row.value][cIndex]"
  350. :key="sIndex"
  351. class="shift"
  352. :style="{ color: viewType === 'users' ? `${shift.color}` : `${ordinateList[rIndex].color}`}"
  353. >
  354. <div v-if="viewType !== 'users'">{{ viewType === 'users'? shift.alias : shift.userName }}</div>
  355. <div v-if="viewType === 'users'" :style="{ width: '20px', height: '20px', backgroundColor: `${shift.color}`, margin: '0 auto', borderRadius: '2px' }"></div>
  356. </div>
  357. <div v-if="hoveredIndex === `${row.value}-${cIndex}` && !readonly" class="overlay">
  358. <i class="el-icon-edit" />
  359. </div>
  360. </div>
  361. <!-- </div> -->
  362. </div>
  363. </div>
  364. </div>
  365. </div>
  366. </div>
  367. <div v-if="activeStep === 3" class="stat">
  368. <statistic
  369. :schedule-data="scheduleData"
  370. :date-list="dateList"
  371. :user-list="ordinateList"
  372. :scheduleShift="formData.scheduleShift"
  373. @close="handleClose"
  374. />
  375. </div>
  376. <context-menu
  377. :viewType="viewType"
  378. :visible.sync="contextMenuVisible"
  379. :params="shiftParams"
  380. :shift-list="viewType === 'users' ? shiftList : userNameList"
  381. :position="itemPosition"
  382. :item-data="selectItem"
  383. :readonly="readonly"
  384. @select="handleSelect"
  385. @close="handleClose"
  386. />
  387. <history
  388. :visible.sync="historyVisible"
  389. @close="v => historyVisible = v"
  390. />
  391. <record
  392. :visible.sync="recordVisible"
  393. :schedule-id="pageParams.id"
  394. @close="v => recordVisible = v"
  395. />
  396. </el-dialog>
  397. </template>
  398. <script>
  399. import { cycleOptions, scheduleType, scheduleColumn } from '../../constants/schedule'
  400. import { queryScheduleConfig, getStaffSchedule, saveStaffSchedule, saveAdjustment, queryStaffSchedule } from '@/api/business/schedule'
  401. import request from '@/utils/request'
  402. import { SYSTEM_URL } from '@/api/baseUrl'
  403. import { previewFile } from '@/api/platform/file/attachment'
  404. import { mapValues, keyBy } from 'lodash'
  405. import html2canvas from 'html2canvas'
  406. import ActionUtils from '@/utils/action'
  407. import { BASE_URL } from '@/constant'
  408. export default {
  409. name: 'schedule',
  410. components: {
  411. ContextMenu: () => import('./components/context-menu'),
  412. Statistic: () => import('./components/statistic'),
  413. History: () => import('./components/history'),
  414. Record: () => import('./components/record')
  415. },
  416. props: {
  417. visible: {
  418. type: Boolean,
  419. default: false
  420. },
  421. pageParams: {
  422. type: Object,
  423. default: () => {}
  424. },
  425. readonly: {
  426. type: Boolean,
  427. default: false
  428. }
  429. },
  430. data () {
  431. const { userList = [], deptList = [] } = this.$store.getters || {}
  432. return {
  433. userList,
  434. deptList,
  435. cycleOptions,
  436. scheduleType,
  437. dialogVisible: this.visible,
  438. formLabelWidth: '120px',
  439. loading: false,
  440. activeStep: 1,
  441. formData: {
  442. title: '',
  443. id: '',
  444. dateRange: [],
  445. config: '',
  446. scheduleStaff: [],
  447. isApproval: 'Y',
  448. approver: [],
  449. scheduleShift: [],
  450. scheduleRule: [],
  451. oldScheduleId: ''
  452. },
  453. isDateChanged: false, // 用于标识日期是否已经改变过
  454. prevDateRange: [],
  455. rules: {},
  456. scheduleColumn,
  457. title: this.readonly ? '创建排班' : '编辑排班',
  458. configList: [],
  459. configOptions: [],
  460. maxHeight: '250px',
  461. undoButtonLabel: '撤销批量修改(0)',
  462. toolbars: [
  463. { key: 'prev', icon: 'el-icon-d-arrow-left', label: '上一步', type: 'primary', steps: '2,3', show: true },
  464. { key: 'next', icon: 'el-icon-d-arrow-right', label: '下一步', type: 'primary', steps: '1,2', show: true },
  465. { key: 'changeView', icon: 'el-icon-set-up', label: '切换视图', type: 'primary', steps: '2', show: true },
  466. // { key: 'history', icon: 'el-icon-time', label: '排班历史', type: 'info', steps: '2,3' },
  467. { key: 'record', icon: 'el-icon-tickets', label: '修改记录', type: 'warning', steps: '2,3', show: true },
  468. { key: 'export', icon: 'el-icon-download', label: '导出', type: 'primary', steps: '2,3', show: true },
  469. { key: 'undo', icon: 'el-icon-refresh-left', label: '撤销批量修改', type: 'info', steps: '2', show: (!this.readonly) },
  470. { key: 'reset', icon: 'el-icon-refresh', label: '重置', type: 'warning', steps: '2', show: (!this.readonly) },
  471. // { key: 'edit', icon: 'el-icon-edit', label: '编辑', type: 'primary', steps: '2,3' },
  472. { key: 'save', icon: 'ibps-icon-save', label: '保存', type: 'primary', show: (!this.readonly) },
  473. { key: 'submit', icon: 'ibps-icon-send', label: '提交', type: 'success', steps: '3', show: (!this.readonly) },
  474. { key: 'cancel', icon: 'el-icon-close', label: '关闭', type: 'danger', show: true }
  475. ],
  476. viewType: 'users',
  477. scheduleData: {},
  478. responseData: {},
  479. userNameList: [],
  480. scheduleRecord: [],
  481. leftOffset: '',
  482. topOffset: '',
  483. topFixed: false,
  484. leftFixed: false,
  485. dateObj: {},
  486. dateList: [],
  487. contextMenuVisible: false,
  488. historyVisible: false,
  489. recordVisible: false,
  490. popoverReference: null,
  491. hoveredIndex: null,
  492. shiftParams: {},
  493. shiftList: [],
  494. selectItem: [],
  495. itemPosition: {},
  496. shiftForm: {
  497. operateType: 'copy',
  498. pasteType: 'all'
  499. },
  500. historyStack: [], // 新增历史记录栈
  501. maxHistorySteps: 10, // 最大历史记录步数
  502. scheduleOptions: [], // 选择排班选项
  503. pickerOptions: {
  504. disabledDate: (time) => {
  505. const { dateRange } = this.formData
  506. if (!dateRange.length) {
  507. return false
  508. }
  509. const startDate = dateRange[0]
  510. const endDate = dateRange[1] || time
  511. // 日期差超过45天则禁用
  512. const diffDays = (endDate - startDate) / (1000 * 60 * 60 * 24)
  513. return diffDays > 45 || diffDays < -45
  514. },
  515. onPick: ({ minDate, maxDate }) => {
  516. if (minDate && !maxDate) {
  517. this.pickerOptions.disabledDate = (time) => {
  518. const dayDifference = (time - minDate) / (1000 * 60 * 60 * 24)
  519. return dayDifference > 45 || dayDifference < -45
  520. }
  521. }
  522. }
  523. }
  524. }
  525. },
  526. computed: {
  527. ordinateList () {
  528. if (this.viewType === 'users') {
  529. const { scheduleStaff } = this.formData
  530. return this.userList.filter(u => scheduleStaff.includes(u.userId)).map(u => ({
  531. label: u.userName,
  532. value: u.userId
  533. }))
  534. } else {
  535. return this.formData.scheduleShift.map(s => ({
  536. label: s.name,
  537. value: s.alias,
  538. color: s.color
  539. }))
  540. }
  541. }
  542. },
  543. watch: {
  544. 'formData.dateRange': {
  545. handler (newValue, oldValue) {
  546. if (oldValue.length > 0) {
  547. this.isDateChanged = true
  548. this.prevDateRange = oldValue
  549. }
  550. },
  551. immediate: false // 不需要在组件初始化时执行,仅在日期真正改变时执行
  552. },
  553. historyStack: {
  554. handler (newVal) {
  555. this.undoButtonLabel = `撤销批量修改(${newVal.length})` // 更新标签
  556. },
  557. immediate: true, // 初始化时立即执行
  558. deep: true // 深度监听
  559. }
  560. },
  561. created () {
  562. this.loadData()
  563. },
  564. mounted () {
  565. this.handleListener('addEventListener')
  566. },
  567. beforeDestroy () {
  568. this.handleListener('removeEventListener')
  569. },
  570. methods: {
  571. handleListener (event) {
  572. setTimeout(() => {
  573. const scrollContainer = this.$refs.scheduleContainer
  574. if (scrollContainer) {
  575. scrollContainer[event]('scroll', this.handleScroll)
  576. console.log(scrollContainer)
  577. }
  578. }, 10)
  579. },
  580. handleScroll () {
  581. const scrollTarget = this.$refs.scrollTarget
  582. const sidebar = this.$refs.sidebar
  583. const scrollContainer = this.$refs.scheduleContainer
  584. const scheduleContent = this.$refs.scheduleContent
  585. let defaultTopOffset = 0
  586. if (scrollContainer.scrollTop > 80) {
  587. this.topFixed = true
  588. scrollTarget.classList.add('stickyTop')
  589. scheduleContent.style.paddingTop = '100px'
  590. defaultTopOffset = 130
  591. this.leftOffset = -scrollContainer.scrollLeft
  592. // this.leftOffset = -scrollContainer.scrollLeft + (this.leftFixed ? 100 : 0)
  593. } else {
  594. this.topFixed = false
  595. scrollTarget.classList.remove('stickyTop')
  596. this.leftOffset = 0
  597. defaultTopOffset = 206
  598. scheduleContent.style.paddingTop = '0px'
  599. }
  600. // // 处理左侧元素的固定
  601. // if (scrollContainer.scrollLeft > 40) {
  602. // this.leftFixed = true
  603. // sidebar.classList.add('stickyLeft')
  604. // this.topOffset = defaultTopOffset - scrollContainer.scrollTop
  605. // scheduleContent.style.paddingLeft = '100px'
  606. // } else {
  607. // this.leftFixed = false
  608. // sidebar.classList.remove('stickyLeft')
  609. // scheduleContent.style.paddingLeft = '0px'
  610. // }
  611. },
  612. getScheduleList (self) { // 获取选择排班下拉的选项
  613. const { first, second } = this.$store.getters.level || {}
  614. const params = {
  615. parameters: [{
  616. key: 'Q^di_dian_^S',
  617. value: second || first
  618. }],
  619. requestPage: {
  620. pageNo: 1,
  621. limit: 99999
  622. },
  623. sorts: []
  624. }
  625. queryStaffSchedule(params).then((res) => {
  626. const scheduleList = res.data.dataResult || []
  627. self.scheduleOptions = scheduleList
  628. })
  629. },
  630. loadData () {
  631. this.loading = true
  632. const self = this
  633. this.getScheduleList(self)
  634. // 获取配置数据
  635. queryScheduleConfig({
  636. parameters: [],
  637. requestPage: {
  638. pageNo: 1,
  639. limit: 1000
  640. },
  641. sorts: []
  642. }).then(async res => {
  643. const { dataResult } = res.data || {}
  644. this.configList = dataResult
  645. const { first, second } = this.$store.getters.level || {}
  646. self.configOptions = self.transformConfigData(dataResult)
  647. self.configOptions.forEach((el) => {
  648. el.options = el.options.filter(obj => obj.diDian === (second || first))
  649. })
  650. // console.log(this.configOptions)
  651. if (this.$utils.isEmpty(this.pageParams.id)) {
  652. this.loading = false
  653. return
  654. }
  655. const response = await getStaffSchedule({ id: this.pageParams.id })
  656. const { staffScheduleDetailPoList: records, title, endDate, startDate, type, overview, config, status, id, oldScheduleId } = response.data
  657. const temp = config ? JSON.parse(config) : {}
  658. this.responseData = response.data
  659. console.log('responseData:', response.data)
  660. console.log('configData:', temp)
  661. this.formData = {
  662. title,
  663. status,
  664. id,
  665. config: temp.id,
  666. dateRange: [startDate, endDate],
  667. approver: temp.approver || [],
  668. oldScheduleId: oldScheduleId,
  669. overview: overview,
  670. scheduleType: type,
  671. scheduleRule: temp.scheduleRule || [],
  672. scheduleShift: temp.scheduleShift || [],
  673. scheduleStaff: temp.scheduleStaff || []
  674. }
  675. this.shiftList = this.formData.scheduleShift.filter(s => s.isEnabled === 'Y').map(s => ({
  676. ...s,
  677. positions: s.positions.join(','),
  678. dateRange: s.dateRange.map(d => {
  679. return d.type === 'allday' ? '全天' : (`当天 ${d.startTime}` + ' 至 ' + `${d.isSecondDay === 'Y' ? '第二天' : '当天'} ${d.endTime}`)
  680. })
  681. }))
  682. console.log('formData', this.formData)
  683. this.scheduleData = this.transformScheduleData(records, overview, temp)
  684. this.responseData = { ...this.responseData, records, overview, temp }
  685. console.log('scheduleData', this.scheduleData)
  686. this.loading = false
  687. }).catch(() => {
  688. this.loading = false
  689. })
  690. },
  691. getDayOfWeek (date) {
  692. const days = ['日', '一', '二', '三', '四', '五', '六']
  693. const dayIndex = new Date(date).getDay()
  694. return {
  695. text: `${days[dayIndex]}`, // 返回星期几的文字
  696. isWeekend: dayIndex === 0 || dayIndex === 6 // 判断是否是周末
  697. }
  698. },
  699. transformConfigData (data) {
  700. return data.reduce((acc, item) => {
  701. const { scheduleType } = item
  702. // 查找是否已存在对应的 scheduleType
  703. const scheduleInfo = this.scheduleType.find(i => i.value === scheduleType)
  704. const existingGroup = acc.find(group => group.labelKey === scheduleType)
  705. if (existingGroup) {
  706. // 如果存在,添加当前项到 options
  707. existingGroup.options.push({
  708. ...item,
  709. value: item.id,
  710. label: `${item.name}${item.isEffective === 'N' ? '(未启用)' : ''}`
  711. })
  712. } else {
  713. // 如果不存在,创建新的分组
  714. acc.push({
  715. labelKey: scheduleType,
  716. label: scheduleInfo.label,
  717. options: [{
  718. ...item,
  719. value: item.id,
  720. label: `${item.name}${item.isEffective === 'N' ? '(未启用)' : ''}`
  721. }]
  722. })
  723. }
  724. return acc
  725. }, [])
  726. },
  727. transformScheduleData (records, overview, { scheduleShift }) {
  728. const result = {}
  729. const temp = overview ? JSON.parse(overview) : {}
  730. records.forEach(({ id, userId, ...days }) => {
  731. result[userId] = []
  732. for (let day = 1; day <= temp.dateCount; day++) {
  733. const shifts = days[`d${day}`] ? days[`d${day}`].split(',') : []
  734. const formattedShifts = shifts.map(shift => {
  735. const temp = scheduleShift.find(s => s.alias === shift)
  736. return temp || {}
  737. })
  738. result[userId].push(formattedShifts)
  739. }
  740. this.scheduleRecord.push({ userId, id })
  741. })
  742. return result
  743. },
  744. /* 切换ScheduleData为人员排班数据格式(人员排班->班次排班)
  745. */
  746. transformScheduleDataForShiftsView (data, userList, scheduleShift, overview) {
  747. const result = []
  748. const temp = overview ? JSON.parse(overview) : {}
  749. // 先创建以排班班次为键的空二维数组
  750. scheduleShift.forEach(shift => {
  751. result[shift.alias] = Array.from({ length: temp.dateCount || this.dateList.length }, () => [])
  752. })
  753. // 再次遍历用户列表,填充每个排班班次对应的用户信息
  754. userList.forEach(({ userId, userName }) => {
  755. data[userId].forEach((day, dayIndex) => {
  756. day.forEach(({ alias }) => {
  757. if (!result[alias][dayIndex]) {
  758. result[alias][dayIndex] = []
  759. }
  760. result[alias][dayIndex].push({
  761. userId,
  762. userName
  763. })
  764. })
  765. })
  766. })
  767. return result
  768. },
  769. /* 复原ScheduleData为人员排班数据格式(班次排班->人员排班)
  770. */
  771. reverseForScheduleData (data, userList, scheduleShift, overview) {
  772. const result = {}
  773. // 先创建以userId为键的空二维数组
  774. const temp = overview ? JSON.parse(overview) : {}
  775. userList.forEach(({ userId }) => {
  776. result[userId] = Array.from({ length: temp.dateCount || this.dateList.length }, () => [])
  777. })
  778. const scheduleShiftMap = scheduleShift.reduce((acc, item) => {
  779. acc[item.alias] = item
  780. return acc
  781. }, {})
  782. // 遍历转换后的数据格式中的每个排班班次
  783. Object.keys(data).forEach(alias => {
  784. const days = data[alias]
  785. const aliasData = scheduleShiftMap[alias]
  786. // 遍历每一天的数据
  787. days.forEach((users, dayIndex) => {
  788. // 遍历当天该班次上的每个用户
  789. users.forEach(({ userId }) => {
  790. // 将当天该用户在该班次的信息添加到对应的用户数据中
  791. if (!result[userId][dayIndex]) {
  792. result[userId][dayIndex] = []
  793. }
  794. result[userId][dayIndex].push(aliasData)
  795. })
  796. })
  797. })
  798. return result
  799. },
  800. handleConfigChange (val) {
  801. const temp = this.configList.find(i => i.id === val)
  802. this.formData = {
  803. ...this.formData,
  804. approver: temp.approver ? temp.approver.split(',') : [],
  805. scheduleType: temp.scheduleType,
  806. isApproval: temp.isApproval,
  807. scheduleRule: temp.scheduleRule ? JSON.parse(temp.scheduleRule) : [],
  808. scheduleShift: temp.scheduleShift ? JSON.parse(temp.scheduleShift) : [],
  809. scheduleStaff: temp.scheduleStaff ? JSON.parse(temp.scheduleStaff) : []
  810. }
  811. this.shiftList = this.formData.scheduleShift.filter(s => s.isEnabled === 'Y').map(s => ({
  812. ...s,
  813. positions: s.positions.join(','),
  814. dateRange: s.dateRange.map(d => {
  815. return d.type === 'allday' ? '全天' : (`当天 ${d.startTime}` + ' 至 ' + `${d.isSecondDay === 'Y' ? '第二天' : '当天'} ${d.endTime}`)
  816. })
  817. }))
  818. },
  819. handleDateChange (dates) {
  820. if (!this.isDateChanged) {
  821. // 第一次选择日期,直接处理业务逻辑,不弹出确认框
  822. if (this.$utils.isEmpty(dates)) {
  823. this.pickerOptions.disabledDate = (time) => {
  824. return false
  825. }
  826. }
  827. } else {
  828. // 不是第一次选择日期,弹出确认框
  829. this.$confirm(
  830. `确定更改排班时间吗?排班数据将会重置`,
  831. '提示:',
  832. {
  833. confirmButtonText: '确定',
  834. cancelButtonText: '取消',
  835. type: 'warning'
  836. }
  837. ).then(() => {
  838. if (this.$utils.isEmpty(dates)) {
  839. this.pickerOptions.disabledDate = (time) => {
  840. return false
  841. }
  842. }
  843. this.scheduleData = mapValues(keyBy(this.ordinateList, 'value'), () => Array.from({ length: this.dateList.length }, () => []))
  844. }).catch(() => {
  845. // 取消操作时的逻辑处理,日期复原
  846. if (this.prevDateRange) {
  847. this.formData.dateRange = this.prevDateRange
  848. }
  849. })
  850. }
  851. },
  852. getDateList (dateRange) {
  853. const [startDate, endDate] = dateRange.map(date => new Date(date))
  854. const dates = {}
  855. const currentDate = startDate
  856. // eslint-disable-next-line no-unmodified-loop-condition
  857. while (currentDate <= endDate) {
  858. const yearMonth = currentDate.toISOString().slice(0, 7) // 获取 'YYYY-MM' 格式
  859. const date = currentDate.toISOString().split('T')[0] // 获取 'YYYY-MM-DD' 格式
  860. if (!dates[yearMonth]) {
  861. dates[yearMonth] = []
  862. }
  863. dates[yearMonth].push(date)
  864. currentDate.setDate(currentDate.getDate() + 1)
  865. }
  866. return dates
  867. },
  868. changeView () {
  869. if (this.userNameList.length < 1) {
  870. this.userNameList = this.ordinateList.map(item => ({ // 存起user对应id和name
  871. userName: item.label,
  872. userId: item.value
  873. }))
  874. }
  875. // 关闭可能存在的弹出框(如果有的话),确保视图切换时界面整洁
  876. this.$refs.popover.showPopper = false
  877. this.handleClose()
  878. // 判断当前视图类型,如果是'users'则切换为'shifts',如果是'shifts'则切换为'users'
  879. this.viewType = this.viewType === 'users' ? 'shifts' : 'users'
  880. // 重新计算scheduleData的结构以适应新视图
  881. if (this.viewType === 'users') {
  882. this.scheduleData = this.reverseForScheduleData(this.scheduleData, this.userNameList, this.formData.scheduleShift, this.formData.overview)
  883. } else {
  884. this.scheduleData = this.transformScheduleDataForShiftsView(this.scheduleData, this.userNameList, this.formData.scheduleShift, this.formData.overview)
  885. }
  886. console.log('changeView', this.scheduleData)
  887. },
  888. handleShiftClick (event, { row, rIndex, column, cIndex }) {
  889. this.selectItem = []
  890. const item = this.$refs.shiftItem[rIndex * this.dateList.length + cIndex]
  891. const rect = item.getBoundingClientRect()
  892. // 计算弹出菜单的位置
  893. let top = rect.top + window.scrollY
  894. let left = rect.left + window.scrollX + rect.width
  895. // 获取窗口的宽度和高度
  896. const windowWidth = window.innerWidth
  897. const windowHeight = window.innerHeight
  898. const menuWidth = 126
  899. const menuHeight = 240
  900. // 检查右侧边界,如果超出右边界,调整到左侧
  901. if (left + menuWidth > windowWidth) {
  902. left = rect.left + window.scrollX - menuWidth
  903. }
  904. // 检查底部边界,如果超出底部边界,调整到顶部
  905. if (top + menuHeight > windowHeight) {
  906. top = rect.top + window.scrollY - menuHeight
  907. }
  908. this.itemPosition = { top, left }
  909. this.shiftParams = {
  910. row,
  911. rIndex,
  912. column,
  913. cIndex
  914. }
  915. this.selectItem = this.scheduleData[row.value][cIndex]
  916. this.contextMenuVisible = true
  917. },
  918. handleSelect ({ selected, params }) {
  919. this.scheduleData[params.row.value][params.cIndex] = selected
  920. },
  921. handleClose () {
  922. this.contextMenuVisible = false
  923. },
  924. handleAction (action) {
  925. switch (action) {
  926. case 'prev':
  927. this.handleStepChange(-1)
  928. break
  929. case 'next':
  930. this.handleStepChange(1)
  931. break
  932. case 'save':
  933. this.handleSave()
  934. break
  935. case 'submit':
  936. this.handleSave('submit')
  937. break
  938. case 'changeView':
  939. this.changeView()
  940. break
  941. case 'history':
  942. this.historyVisible = true
  943. break
  944. case 'export':
  945. this.handleExport(this)
  946. break
  947. case 'record':
  948. this.recordVisible = true
  949. break
  950. case 'reset':
  951. this.handleReset()
  952. break
  953. case 'cancel':
  954. this.closeDialog()
  955. break
  956. case 'undo':
  957. this.handleUndo()
  958. break
  959. default:
  960. break
  961. }
  962. },
  963. handleStepChange (val) {
  964. this.handleClose()
  965. if (this.activeStep === 1 && val) {
  966. const valid = this.validateForm()
  967. if (!valid) {
  968. this.$message.warning('请完善必填信息后再操作!')
  969. return
  970. }
  971. this.dateObj = this.getDateList(this.formData.dateRange)
  972. this.dateList = Object.values(this.dateObj).flat()
  973. if (!this.pageParams.id && !this.formData.oldScheduleId) {
  974. this.scheduleData = mapValues(keyBy(this.ordinateList, 'value'), () => Array.from({ length: this.dateList.length }, () => []))
  975. } else {
  976. this.updateScheduleData()
  977. }
  978. }
  979. if (this.activeStep === 2 && this.viewType === 'shifts') { // 班次排班需还原数据
  980. if (val > 0) {
  981. this.changeView() // 切换为人员排班
  982. }
  983. }
  984. this.handleListener('addEventListener')
  985. this.activeStep += val
  986. },
  987. async handleScheduleChange (val) { // 选择排班带出配置
  988. const self = this
  989. if (val) {
  990. await getStaffSchedule({ id: val }).then((res) => {
  991. const { staffScheduleDetailPoList: records, title, endDate, startDate, overview, config,type } = res.data
  992. const temp = config ? JSON.parse(config) : {}
  993. self.responseData = res.data
  994. self.formData = {
  995. ...self.formData,
  996. title,
  997. config: temp.id,
  998. dateRange: [startDate, endDate],
  999. approver: temp.approver || [],
  1000. overview: overview,
  1001. scheduleType: type,
  1002. scheduleRule: temp.scheduleRule || [],
  1003. scheduleShift: temp.scheduleShift || [],
  1004. scheduleStaff: temp.scheduleStaff || []
  1005. }
  1006. self.shiftList = self.formData.scheduleShift.filter(s => s.isEnabled === 'Y').map(s => ({
  1007. ...s,
  1008. positions: s.positions.join(','),
  1009. dateRange: s.dateRange.map(d => {
  1010. return d.type === 'allday' ? '全天' : (`当天 ${d.startTime}` + ' 至 ' + `${d.isSecondDay === 'Y' ? '第二天' : '当天'} ${d.endTime}`)
  1011. })
  1012. }))
  1013. console.log('formData', self.formData)
  1014. self.scheduleData = self.transformScheduleData(records, overview, temp)
  1015. self.responseData = { ...self.responseData, records, overview, temp }
  1016. })
  1017. }
  1018. },
  1019. /**
  1020. * 更新排班数据
  1021. * 1. 获取当前的 ordinateList 的 value 列表
  1022. * 2. 删除 this.scheduleData 中不在 newOrdinateValues 里的项
  1023. * 3. 确保 this.scheduleData 包含所有 newOrdinateValues 的项
  1024. * 4. 更新数组长度
  1025. */
  1026. updateScheduleData () {
  1027. const newOrdinateValues = this.ordinateList.map(ordinate => ordinate.value)
  1028. Object.keys(this.scheduleData).forEach(key => {
  1029. if (!newOrdinateValues.includes(key)) {
  1030. delete this.scheduleData[key]
  1031. }
  1032. })
  1033. newOrdinateValues.forEach(key => {
  1034. if (!this.scheduleData[key]) {
  1035. this.scheduleData[key] = Array.from({ length: this.dateList.length }, () => [])
  1036. }
  1037. })
  1038. Object.keys(this.scheduleData).forEach(key => {
  1039. const scheduleArray = this.scheduleData[key]
  1040. const newLength = this.dateList.length
  1041. if (scheduleArray.length < newLength) {
  1042. // 如果当前长度小于新的长度,添加新的空数组
  1043. for (let i = scheduleArray.length; i < newLength; i++) {
  1044. scheduleArray.push([])
  1045. }
  1046. } else if (scheduleArray.length > newLength) {
  1047. // 如果当前长度大于新的长度,裁剪数组
  1048. scheduleArray.length = newLength
  1049. }
  1050. })
  1051. },
  1052. validateForm () {
  1053. const { scheduleRule, approver, status, ...rest } = this.formData
  1054. const result = Object.keys(rest).some(k => this.$utils.isEmpty(rest[k]) && k !== 'id' && k !== 'oldScheduleId')
  1055. return !result
  1056. },
  1057. dealData (data) {
  1058. if (this.viewType === 'shifts') { // 班次排班需还原数据
  1059. data = this.reverseForScheduleData(this.scheduleData, this.userNameList, this.formData.scheduleShift, this.formData.overview)
  1060. }
  1061. var dateCount = 0
  1062. const result = Object.entries(data).map(([userId, days]) => {
  1063. const userObj = { userId, statistics: {}}
  1064. const temp = this.scheduleRecord.find(i => i.userId === userId)
  1065. const recordId = temp ? temp.id : ''
  1066. dateCount = days.length
  1067. days.forEach((day, index) => {
  1068. const shifts = day.map(({ alias }) => alias).join(',') || ''
  1069. userObj[`d${index + 1}`] = shifts
  1070. // 统计每个alias出现的次数
  1071. day.forEach(({ alias }) => {
  1072. if (userObj.statistics[alias]) {
  1073. userObj.statistics[alias]++
  1074. } else {
  1075. userObj.statistics[alias] = 1
  1076. }
  1077. })
  1078. })
  1079. return {
  1080. ...userObj,
  1081. id: recordId,
  1082. pk: recordId,
  1083. statistics: JSON.stringify(userObj.statistics)
  1084. }
  1085. })
  1086. console.log(result)
  1087. // 统计所有userId中各个alias的平均出现次数
  1088. const total = {}
  1089. const userCount = result.length
  1090. result.forEach(user => {
  1091. Object.entries(JSON.parse(user.statistics)).forEach(([alias, count]) => {
  1092. if (total[alias]) {
  1093. total[alias] += count
  1094. } else {
  1095. total[alias] = count
  1096. }
  1097. })
  1098. })
  1099. const avg = {}
  1100. Object.entries(total).forEach(([alias, totalCount]) => {
  1101. avg[alias] = totalCount / userCount
  1102. })
  1103. const overview = {
  1104. avg,
  1105. total,
  1106. userCount,
  1107. dateCount
  1108. }
  1109. return { staffScheduleDetailPoList: result, overview }
  1110. },
  1111. handleSave (type) {
  1112. const { staffScheduleDetailPoList, overview } = this.dealData(this.scheduleData) || {}
  1113. const { dateRange, title, config, approver, scheduleType, status, scheduleShift, scheduleStaff, scheduleRule, id, oldScheduleId } = this.formData
  1114. const { first, second } = this.$store.getters.level || {}
  1115. const configData = {
  1116. id: config,
  1117. approver,
  1118. scheduleShift,
  1119. scheduleStaff,
  1120. scheduleRule
  1121. }
  1122. const submitData = {
  1123. id: this.pageParams.id || id,
  1124. pk: this.pageParams.id || id,
  1125. diDian: second || first,
  1126. title,
  1127. oldScheduleId,
  1128. status: type ? '已发布' : '未发布',
  1129. startDate: dateRange[0],
  1130. endDate: dateRange[1],
  1131. type: scheduleType,
  1132. config: JSON.stringify(configData),
  1133. overview: JSON.stringify(overview),
  1134. staffScheduleDetailPoList
  1135. }
  1136. console.log(submitData)
  1137. this.loading = true
  1138. saveStaffSchedule(submitData).then(async (res) => {
  1139. if (res.variables.id) {
  1140. this.formData.id = res.variables.id
  1141. const response = await getStaffSchedule({ id: this.formData.id })
  1142. this.responseData = response.data
  1143. }
  1144. // 增加一条调班申请记录,用于查看排班管理员修改历史。
  1145. this.submitAdjust(submitData).then(() => {
  1146. if (type) { // 提交
  1147. this.handleSaveNews().then(() => {
  1148. this.loading = false
  1149. this.activeStep = 3
  1150. this.$message.success('提交成功')
  1151. this.$confirm('退出当前页面?', '提示', {
  1152. confirmButtonText: '确定',
  1153. cancelButtonText: '取消',
  1154. type: 'warning'
  1155. }).then(() => {
  1156. this.closeDialog()
  1157. this.$emit('callback')
  1158. }).catch(() => {
  1159. this.$emit('callback')
  1160. })
  1161. }).catch(() => {
  1162. this.$message.error('提交失败')
  1163. })
  1164. } else { // 保存
  1165. this.$message.success('保存成功')
  1166. this.loading = false
  1167. // this.closeDialog()
  1168. this.$emit('callback')
  1169. }
  1170. })
  1171. }).catch(() => {
  1172. this.$message.error('保存失败')
  1173. this.loading = false
  1174. })
  1175. },
  1176. async handleSaveNews () {
  1177. this.activeStep = 2
  1178. this.$nextTick(async () => {
  1179. const element = this.$refs.schedule
  1180. console.log(element)
  1181. const filePath = await this.captureAndUpload(element)
  1182. const fileUrl = BASE_URL + filePath
  1183. // await previewFile(fileId)
  1184. const { userId, name } = this.$store.getters
  1185. const { first, second } = this.$store.getters.level
  1186. const { title, dateRange } = this.formData
  1187. const news = {
  1188. author: name,
  1189. content: `<img src="${fileUrl}" title="${title}.png" alt="image.png"/>`,
  1190. depId: '',
  1191. depName: '',
  1192. fileAttach: '',
  1193. includeChild: 'N',
  1194. // 发布时间
  1195. publicDate: this.$common.getDateNow(19),
  1196. // 失效时间
  1197. loseDate: dateRange[1] + ' 23:59:59',
  1198. public0: 'Y',
  1199. publicItem: 'notices',
  1200. status: 'publish',
  1201. title: title,
  1202. type: second || first,
  1203. userId: userId,
  1204. userName: name
  1205. }
  1206. return this.$common.saveNews(news)
  1207. })
  1208. },
  1209. captureAndUpload (element) {
  1210. const uploadImage = (blob) => {
  1211. return new Promise((resolve, reject) => {
  1212. const data = new FormData() // 创建form对象
  1213. data.append('file', blob, `${this.formData.title}.png`)
  1214. request({
  1215. url: SYSTEM_URL() + '/file/upload',
  1216. method: 'post',
  1217. isLoading: true,
  1218. gateway: true,
  1219. data
  1220. }).then(res => {
  1221. resolve(res.data.filePath || '')
  1222. }).catch(error => {
  1223. reject(error)
  1224. })
  1225. })
  1226. }
  1227. const canvasToBlob = (canvas) => {
  1228. return new Promise(resolve => {
  1229. canvas.toBlob(blob => {
  1230. resolve(blob)
  1231. }, 'image/png', 1.0)
  1232. })
  1233. }
  1234. return new Promise((resolve, reject) => {
  1235. // 修改变形样式
  1236. const abscissaElement = document.querySelector('.abscissa')
  1237. if (abscissaElement) {
  1238. abscissaElement.style.margin = '0 20px 0 90px'
  1239. }
  1240. html2canvas(element).then(canvas => {
  1241. canvasToBlob(canvas).then(blob => {
  1242. uploadImage(blob).then(fileId => {
  1243. resolve(fileId)
  1244. })
  1245. })
  1246. })
  1247. })
  1248. },
  1249. async handleExport (self) {
  1250. this.loading = true
  1251. const step = this.activeStep
  1252. this.activeStep = 2
  1253. self.$nextTick(() => {
  1254. const element = self.$refs.schedule
  1255. if (element) {
  1256. // 使用 html2canvas 渲染 DOM 元素
  1257. html2canvas(element, {
  1258. scrollX: 0,
  1259. scrollY: -10,
  1260. width: element.scrollWidth,
  1261. height: element.scrollHeight
  1262. }).then(canvas => {
  1263. const link = document.createElement('a')
  1264. link.href = canvas.toDataURL('image/png')
  1265. link.download = `${self.formData.title || '排班'}.png`
  1266. link.click()
  1267. this.activeStep = step
  1268. this.loading = false
  1269. }).catch(err => {
  1270. this.loading = false
  1271. console.error('导出失败', err)
  1272. })
  1273. }
  1274. })
  1275. },
  1276. handleReset () {
  1277. this.$confirm('<p style="font-size: 18px;">重置后当前排班的信息将会清空,您确定要执行该操作吗?</p>', '提示', {
  1278. confirmButtonText: '确认',
  1279. cancelButtonText: '取消',
  1280. type: 'warning',
  1281. showClose: false,
  1282. closeOnClickModal: false,
  1283. dangerouslyUseHTMLString: true
  1284. }).then(() => {
  1285. this.scheduleData = mapValues(keyBy(this.ordinateList, 'value'), () => Array.from({ length: this.dateList.length }, () => []))
  1286. }).catch(() => {
  1287. // nothing
  1288. })
  1289. },
  1290. showShiftSetting (e, date) {
  1291. this.resetShiftSetting()
  1292. this.shiftForm.current = date
  1293. this.popoverReference = e.target
  1294. this.$refs.popover.showPopper = true
  1295. // 先把编辑菜单关闭
  1296. this.handleClose()
  1297. // 手动更改弹窗位置
  1298. setTimeout(() => {
  1299. const popoverElement = document.querySelector('.el-popover')
  1300. if (popoverElement) {
  1301. const rect = popoverElement.getBoundingClientRect()
  1302. const targetRect = e.target.getBoundingClientRect()
  1303. // 计算新的位置
  1304. let left = targetRect.left + (targetRect.width / 2) - (rect.width / 2)
  1305. // 使用目标元素的底部作为弹窗顶部
  1306. const top = targetRect.bottom
  1307. const windowWidth = window.innerWidth
  1308. // 左右边界检查
  1309. if (left < 0) {
  1310. left = 0
  1311. } else if (left + rect.width > windowWidth) {
  1312. left = windowWidth - rect.width
  1313. }
  1314. // 设置弹窗位置
  1315. popoverElement.style.left = `${left}px`
  1316. popoverElement.style.top = `${top}px`
  1317. const triangle = popoverElement.querySelector('.popper__arrow')
  1318. // 设置小三角位置
  1319. if (triangle) {
  1320. triangle.style.left = `${(targetRect.left + (targetRect.width / 2)) - left}px`
  1321. }
  1322. }
  1323. }, 10)
  1324. },
  1325. resetShiftSetting () {
  1326. this.popoverReference = null
  1327. this.shiftForm = {
  1328. operateType: 'copy',
  1329. pasteType: 'all'
  1330. }
  1331. this.$refs.popover.showPopper = false
  1332. },
  1333. getDays (start, end) {
  1334. if (!start || !end) {
  1335. return 0
  1336. }
  1337. return Math.ceil((new Date(end) - new Date(start)) / (1000 * 60 * 60 * 24))
  1338. },
  1339. handleShiftSetting (type) {
  1340. // 保存当前状态到历史记录
  1341. this.pushHistory()
  1342. const { cycle, dates, operateType, current } = this.shiftForm || {}
  1343. if ((operateType === 'copy' && this.$utils.isEmpty(dates)) || (operateType === 'cycle' && this.$utils.isEmpty(cycle))) {
  1344. return this.$message.warning('请补充必填信息!')
  1345. }
  1346. const target = this.getDays(this.formData.dateRange[0], current)
  1347. const total = this.getDays(this.formData.dateRange[0], this.formData.dateRange[1])
  1348. if (operateType === 'copy') {
  1349. dates.forEach(d => {
  1350. const index = this.getDays(this.formData.dateRange[0], d)
  1351. Object.keys(this.scheduleData).forEach(key => {
  1352. if (type === 'all' || this.$utils.isEmpty(this.scheduleData[key][index])) {
  1353. this.scheduleData[key][index] = [...this.scheduleData[key][target]]
  1354. }
  1355. })
  1356. })
  1357. } else {
  1358. // 获取需覆盖的下标数组
  1359. const getIndexList = (start, end, step) => {
  1360. const result = []
  1361. let cur = start + step + 1
  1362. while (cur <= end) {
  1363. result.push(cur)
  1364. cur += step + 1
  1365. }
  1366. return result
  1367. }
  1368. const indexList = getIndexList(target, total, cycle)
  1369. indexList.forEach(i => {
  1370. Object.keys(this.scheduleData).forEach(key => {
  1371. if (type === 'all' || this.$utils.isEmpty(this.scheduleData[key][i])) {
  1372. this.scheduleData[key][i] = [...this.scheduleData[key][target]]
  1373. }
  1374. })
  1375. })
  1376. }
  1377. this.resetShiftSetting()
  1378. },
  1379. pushHistory () {
  1380. // 保留最多maxHistorySteps步历史记录
  1381. if (this.historyStack.length >= this.maxHistorySteps) {
  1382. this.historyStack.shift()
  1383. }
  1384. // 深拷贝当前排班数据
  1385. this.historyStack.push(JSON.parse(JSON.stringify(this.scheduleData)))
  1386. },
  1387. handleUndo () {
  1388. if (this.historyStack.length === 0) {
  1389. this.$message.warning('已无更多可撤销操作')
  1390. return
  1391. }
  1392. // 取出最近的历史记录
  1393. const history = this.historyStack.pop()
  1394. // 恢复排班数据
  1395. this.scheduleData = history
  1396. this.$message.success('撤销成功')
  1397. this.$forceUpdate() // 强制更新视图
  1398. },
  1399. /** 获取排班变化描述 */
  1400. getOverViews (responseData, newData) {
  1401. const oldData = responseData.staffScheduleDetailPoList
  1402. const result = []
  1403. // 遍历newData
  1404. newData.forEach((newItem, i) => {
  1405. // 比较每个人班次的数量变化
  1406. if (newItem.statistics !== oldData[i].statistics) {
  1407. const newStatistics = JSON.parse(newItem.statistics) || []
  1408. const oldStatistics = JSON.parse(oldData[i].statistics) || []
  1409. const changes = []
  1410. // 比较每个人班次的数量变化
  1411. for (const shift in newStatistics) {
  1412. const newCount = newStatistics[shift] || 0
  1413. const oldCount = oldStatistics[shift] || 0
  1414. const shiftName = this.formData.scheduleShift.find(item => item.alias === shift)?.name
  1415. if (newCount > oldCount) {
  1416. changes.push(`${shiftName}增加了` + (newCount - oldCount) + '班次')
  1417. } else if (newCount < oldCount) {
  1418. changes.push(`${shiftName}减少了` + (oldCount - newCount) + '班次')
  1419. }
  1420. }
  1421. // oldStatistics中有的项而newStatistics中没有的项
  1422. for (const oldShift in oldStatistics) {
  1423. if (!newStatistics.hasOwnProperty(oldShift)) {
  1424. const shiftName = this.formData.scheduleShift.find(item => item.alias === oldShift)?.name
  1425. const oldCount = oldStatistics[oldShift] || 0
  1426. changes.push(`${shiftName}减少了` + oldCount + '班次')
  1427. }
  1428. }
  1429. // 如果有班次变化,将其与userId记录到结果中
  1430. if (changes.length > 0) {
  1431. const userNameObj = this.userList.filter(item => item.userId === newItem.userId)
  1432. const perOverView = userNameObj[0]?.userName + changes.join(',')
  1433. result.push(perOverView)
  1434. }
  1435. }
  1436. })
  1437. return result.join('。')
  1438. },
  1439. // 提交调班申请数据
  1440. async submitAdjust (submitData) {
  1441. let overView = ''
  1442. if (submitData.id) {
  1443. overView = this.getOverViews(this.responseData || null, submitData.staffScheduleDetailPoList)
  1444. }
  1445. if (overView === '') {
  1446. return
  1447. }
  1448. const { first, second } = this.$store.getters.level || {}
  1449. const adjustData = {
  1450. scheduleId: submitData.id,
  1451. reason: '排班管理员调整排班',
  1452. diDian: second || first,
  1453. overview: '排班管理员调整:' + overView,
  1454. status: '已通过',
  1455. updateTime: Date.now(),
  1456. adjustmentDetailPoList: []
  1457. }
  1458. await saveAdjustment(adjustData)
  1459. },
  1460. closeDialog () {
  1461. this.$emit('close', false)
  1462. }
  1463. }
  1464. }
  1465. </script>
  1466. <style lang="scss" scoped>
  1467. .schedule-edit-dialog {
  1468. width: 100%;
  1469. ::v-deep {
  1470. .el-dialog__header {
  1471. padding: 15px 20px 16px;
  1472. }
  1473. .el-dialog__footer {
  1474. display: none;
  1475. }
  1476. .el-dialog__body {
  1477. // overflow: hidden;
  1478. }
  1479. }
  1480. .edit-dialog-header {
  1481. display: flex;
  1482. justify-content: space-between;
  1483. align-items: center;
  1484. .title {
  1485. line-height: 24px;
  1486. font-size: 18px;
  1487. color: #303133;
  1488. }
  1489. }
  1490. .schedule-form {
  1491. width: 1080px;
  1492. height: 600px;
  1493. margin: 20px auto;
  1494. ::v-deep {
  1495. .el-form-item {
  1496. margin-bottom: 16px !important;
  1497. }
  1498. .el-input, .el-select, .el-input-number, .el-range-editor {
  1499. width: 100%;
  1500. }
  1501. }
  1502. }
  1503. .schedule-container {
  1504. width: 100%;
  1505. max-height: calc(100vh - 146px);
  1506. overflow: auto;
  1507. ::v-deep {
  1508. .el-button > span {
  1509. margin-left: 5px;
  1510. }
  1511. }
  1512. .page-header {
  1513. padding: 20px;
  1514. height: 80px;
  1515. .toolbar {
  1516. text-align: right;
  1517. }
  1518. .page-title {
  1519. margin-top: 20px;
  1520. font-size: 24px;
  1521. font-weight: 600;
  1522. text-align: center;
  1523. }
  1524. }
  1525. .schedule-box {
  1526. width: fit-content;
  1527. overflow-x: auto;
  1528. font-size: 14px;
  1529. //position: relative;
  1530. .abscissa {
  1531. // width: 100%;
  1532. margin: 0 20px 0 110px;
  1533. display: flex;
  1534. width: fit-content;
  1535. .abs-type {
  1536. .month {
  1537. display: flex;
  1538. align-items: center;
  1539. justify-content: center;
  1540. text-align: center;
  1541. height: 29px;
  1542. border: 1px solid #ccc;
  1543. border-bottom: none;
  1544. border-right: none;
  1545. }
  1546. .abscissa-item {
  1547. display: flex;
  1548. align-items: center;
  1549. justify-content: center;
  1550. .dateweek{
  1551. .date {
  1552. width: 59px;
  1553. height: 28px;
  1554. line-height: 30px;
  1555. text-align: center;
  1556. border: 1px solid #ccc;
  1557. border-right: none;
  1558. cursor: pointer;
  1559. color: #409eff;
  1560. }
  1561. .week {
  1562. width: 59px;
  1563. height: 28px;
  1564. line-height: 30px;
  1565. text-align: center;
  1566. border: 1px solid #ccc;
  1567. border-right: none;
  1568. border-top: none;
  1569. color: rgb(96, 98, 102);
  1570. }
  1571. cursor: pointer;
  1572. &:hover {
  1573. background: #ecf5ff;
  1574. }
  1575. .weekend-bg {
  1576. background-color: rgb(223 223 223 / 90%); // 周末的背景色
  1577. }
  1578. }
  1579. }
  1580. &:last-child {
  1581. .month {
  1582. border-right: 1px solid #ccc;
  1583. }
  1584. .abscissa-item > div {
  1585. &:last-child {
  1586. border-right: 1px solid #ccc;
  1587. }
  1588. }
  1589. }
  1590. }
  1591. }
  1592. .schedule-content {
  1593. // max-height: calc(100vh - 245px);
  1594. height: fit-content;
  1595. margin: 10px 0 20px 10px;
  1596. position: relative;
  1597. display: flex;
  1598. flex-direction: row;
  1599. align-items: stretch;
  1600. float: left;
  1601. overflow-y: auto;
  1602. white-space: nowrap;
  1603. .ordinate {
  1604. width: 80px;
  1605. padding: 0 10px;
  1606. // height: 100%;
  1607. // background-color: #f0f0f0;
  1608. display: flex;
  1609. flex-shrink: 0;
  1610. flex-direction: column;
  1611. .ordinate-item {
  1612. display: flex;
  1613. align-items: center;
  1614. justify-content: center;
  1615. text-align: center;
  1616. min-height: 60px;
  1617. flex-shrink: 0;
  1618. flex-grow: 1;
  1619. }
  1620. }
  1621. .shift-content {
  1622. flex: 1;
  1623. margin-right: 20px;
  1624. .shift-row {
  1625. display: flex;
  1626. .shift-item {
  1627. position: relative;
  1628. width: 59px;
  1629. min-height: 59px;
  1630. font-size: 14px;
  1631. border: 1px solid #ccc;
  1632. border-bottom: none;
  1633. border-right: none;
  1634. cursor: context-menu;
  1635. /*filter: saturate(0.8);*/
  1636. display: flex;
  1637. flex-direction: column;
  1638. justify-content: center;
  1639. align-items: center ;
  1640. .shift {
  1641. text-align: center;
  1642. }
  1643. &:has(.shift:nth-child(2)) {
  1644. flex-direction: column;
  1645. }
  1646. &:has(.shift:nth-child(3)) {
  1647. flex-direction: column;
  1648. display: grid;
  1649. grid-template-columns: repeat(2, 1fr);
  1650. gap: 1px;
  1651. }
  1652. &:has(.shift:nth-of-type(odd):nth-last-of-type(odd)):not(:has(.overlay)) {
  1653. // background: #303133;
  1654. .shift:last-of-type {
  1655. grid-column: span 2;
  1656. justify-self: center;
  1657. }
  1658. }
  1659. &:last-child {
  1660. border-right: 1px solid #ccc;
  1661. }
  1662. .overlay {
  1663. position: absolute;
  1664. top: 0;
  1665. left: 0;
  1666. right: 0;
  1667. bottom: 0;
  1668. background: rgba(0, 0, 0, 0.3); /* 半透明遮罩 */
  1669. display: flex;
  1670. justify-content: center;
  1671. align-items: center;
  1672. color: white;
  1673. }
  1674. // .shift-item {
  1675. // width: 100%;
  1676. // height: 100%;
  1677. // font-size: 14px;
  1678. // .shift:last-child {
  1679. // margin-left: 5px;
  1680. // }
  1681. // .shift:only-child {
  1682. // margin-left: 0;
  1683. // }
  1684. // }
  1685. }
  1686. &:last-of-type {
  1687. .shift-item {
  1688. border-bottom: 1px solid #ccc;
  1689. }
  1690. }
  1691. }
  1692. }
  1693. }
  1694. .stickyTop {
  1695. position: fixed;
  1696. top: 80px;
  1697. left: 0;
  1698. right: 0;
  1699. z-index: 1001;
  1700. background: #fff;
  1701. }
  1702. .stickyLeft {
  1703. position: fixed;
  1704. top: 206px;
  1705. left: 0;
  1706. right: 0;
  1707. z-index: 1000;
  1708. background: #fff;
  1709. box-shadow: 0 2px 20px rgba(228, 231, 237, 1);
  1710. }
  1711. }
  1712. .shift-legend {
  1713. display: flex;
  1714. align-items: center;
  1715. margin: 0px 0 10px 110px;
  1716. border-radius: 4px;
  1717. .legend-title {
  1718. //font-weight: bold;
  1719. margin-right: 15px;
  1720. color: rgb(96, 98, 102);
  1721. }
  1722. .legend-items {
  1723. display: flex;
  1724. flex-wrap: wrap;
  1725. gap: 15px; // 色块间距
  1726. }
  1727. .legend-item {
  1728. display: flex;
  1729. align-items: center;
  1730. gap: 6px;
  1731. }
  1732. .color-block {
  1733. width: 20px;
  1734. height: 20px;
  1735. border-radius: 2px;
  1736. flex-shrink: 0;
  1737. }
  1738. .shift-name {
  1739. font-size: 12px;
  1740. color: rgb(96, 98, 102);
  1741. }
  1742. }
  1743. }
  1744. }
  1745. </style>