index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729
  1. <template>
  2. <schedule-wrapper
  3. class="c-schedule-calendar"
  4. v-bind="scheduleOptions"
  5. :dirty="dirty"
  6. @add="addProgram"
  7. @submit="submit"
  8. @save="onSave"
  9. @click.stop
  10. >
  11. <template #header>
  12. <button
  13. v-if="editable"
  14. class="l-flex__none c-sibling-item o-button"
  15. @click="today"
  16. >
  17. 本月
  18. </button>
  19. <i
  20. class="c-schedule-calendar__btn el-icon-arrow-left c-sibling-item u-pointer"
  21. :class="{ show: canToPresent }"
  22. @click="toPresent"
  23. />
  24. <span class="c-schedule-calendar__time u-readonly">{{ date }}</span>
  25. <i
  26. class="c-schedule-calendar__btn el-icon-arrow-right u-pointer"
  27. :class="{ show: canToNext }"
  28. @click="toNext"
  29. />
  30. </template>
  31. <div class="l-flex__auto l-flex--col c-schedule-calendar__main">
  32. <div class="l-flex__none l-flex c-schedule-calendar__header">
  33. <div class="l-flex__auto u-readonly">周日</div>
  34. <div class="l-flex__auto u-readonly">周一</div>
  35. <div class="l-flex__auto u-readonly">周二</div>
  36. <div class="l-flex__auto u-readonly">周三</div>
  37. <div class="l-flex__auto u-readonly">周四</div>
  38. <div class="l-flex__auto u-readonly">周五</div>
  39. <div class="l-flex__auto u-readonly">周六</div>
  40. </div>
  41. <div class="c-schedule-calendar__content">
  42. <div
  43. v-for="(week, weekIndex) in weeks"
  44. :key="weekIndex"
  45. class="l-flex c-schedule-calendar__row"
  46. >
  47. <div
  48. v-for="(day, dayIndex) in week"
  49. :key="dayIndex"
  50. class="c-schedule-calendar__col day c-day-card"
  51. :class="{ 'enabled': day.editable, 'disabled': day.disabled, 'invalid': day.invalid }"
  52. @dblclick="addProgramByDay(day)"
  53. >
  54. <span class="c-day-card__day">{{ day.value }}</span>
  55. <div
  56. v-if="day.programs.length > 0"
  57. class="l-flex--row"
  58. >
  59. <pop-detail
  60. class="l-flex__auto"
  61. :program="day.programs[0]"
  62. >
  63. <program-item
  64. class="c-day-card__program"
  65. :program="day.programs[0]"
  66. :editable="editable"
  67. @edit="editProgram"
  68. @remove="removeProgram"
  69. />
  70. </pop-detail>
  71. </div>
  72. <pop-list
  73. v-if="day.programs.length > 1"
  74. class="l-flex__auto"
  75. :programs="day.programs"
  76. :editable="editable"
  77. @edit="editProgram"
  78. @remove="removeProgram"
  79. >
  80. <div class="c-day-card__count u-ellipsis u-pointer">
  81. +{{ day.programs.length - 1 }}
  82. </div>
  83. </pop-list>
  84. </div>
  85. </div>
  86. </div>
  87. </div>
  88. <el-dialog
  89. title="排期设置"
  90. :visible.sync="editing"
  91. custom-class="c-dialog"
  92. :close-on-click-modal="false"
  93. :before-close="handleCloseEditDialog"
  94. >
  95. <schedule-edit
  96. v-if="program"
  97. ref="editor"
  98. :program="program"
  99. :ratio="ratio"
  100. :min-date="min"
  101. :max-date="max"
  102. />
  103. <template #footer>
  104. <button
  105. class="o-button"
  106. @click="saveProgram"
  107. >
  108. 确定
  109. </button>
  110. <button
  111. class="o-button cancel"
  112. @click="handleCloseEditDialog"
  113. >
  114. 取消
  115. </button>
  116. </template>
  117. </el-dialog>
  118. <el-dialog
  119. title="冲突提醒"
  120. :visible.sync="conflicting"
  121. custom-class="c-dialog"
  122. :close-on-click-modal="false"
  123. append-to-body
  124. >
  125. <div class="has-bottom-padding u-bold">
  126. <div>局部覆盖:被冲突的节目的时间段将被切割</div>
  127. <div>覆盖:被冲突的节目将被移除</div>
  128. </div>
  129. <div
  130. v-for="conflict in conflicts"
  131. :key="conflict.key"
  132. >
  133. 与 {{ conflict.target.name }} 于 {{ conflict.start }} ~ {{ conflict.end }} 冲突
  134. </div>
  135. <template #footer>
  136. <button
  137. class="o-button"
  138. @click="_coverConflicts"
  139. >
  140. 局部覆盖
  141. </button>
  142. <button
  143. class="o-button"
  144. @click="_coverConflictsFull"
  145. >
  146. 覆盖
  147. </button>
  148. <button
  149. class="o-button cancel"
  150. @click="conflicting = false"
  151. >
  152. 取消
  153. </button>
  154. </template>
  155. </el-dialog>
  156. </schedule-wrapper>
  157. </template>
  158. <script>
  159. import { parseTime } from '@/utils'
  160. import scheduleMixin from '../mixins/schedule'
  161. import ScheduleWrapper from '../components/ScheduleWrapper'
  162. import ScheduleEdit from './ScheduleEdit'
  163. import ProgramItem from './ProgramItem'
  164. import PopDetail from './PopDetail'
  165. import PopList from './PopList'
  166. export default {
  167. name: 'ScheduleCalendar',
  168. components: {
  169. ScheduleWrapper,
  170. ScheduleEdit,
  171. ProgramItem,
  172. PopDetail,
  173. PopList
  174. },
  175. mixins: [scheduleMixin],
  176. data () {
  177. return {
  178. weeks: [],
  179. min: null,
  180. max: null,
  181. current: null,
  182. editing: false,
  183. program: null,
  184. dirty: false,
  185. conflicting: false,
  186. conflicts: []
  187. }
  188. },
  189. computed: {
  190. date () {
  191. return this.current ? `${this.current.year}.${this.current.month + 1}` : ''
  192. },
  193. canToPresent () {
  194. return this.current ? this._compare(this.current, this.min, false) > 0 : false
  195. },
  196. canToNext () {
  197. return this.current && this.max ? this._compare(this.current, this.max, false) < 0 : !!this.current
  198. }
  199. },
  200. methods: {
  201. init () {
  202. const now = this._transform(Date.now())
  203. let current = {
  204. year: now.year,
  205. month: now.month
  206. }
  207. if (this.editable) {
  208. this._setMinDate()
  209. if (this.programs.length) {
  210. const minProgramDate = this._transform(this.programs[0].startDateTime)
  211. if (this._compare(now, minProgramDate, false) < 0) {
  212. current = {
  213. year: minProgramDate.year,
  214. month: minProgramDate.month
  215. }
  216. }
  217. }
  218. } else {
  219. if (this.programs.length) {
  220. this.min = this._transform(this.programs[0].startDateTime)
  221. this.max = this.programs.reduce((curr, { endDateTime }) => {
  222. const date = this._transform(endDateTime)
  223. if (curr) {
  224. return this._compare(curr, date) < 0 ? date : curr
  225. }
  226. return date
  227. }, null)
  228. } else {
  229. this.max = this.min = now
  230. }
  231. if (this._compare(now, this.max, false) > 0) {
  232. current = {
  233. year: this.min.year,
  234. month: this.min.month
  235. }
  236. }
  237. }
  238. this.current = current
  239. this._calculate()
  240. },
  241. fix () {
  242. let endTime = Date.now() + 60000
  243. if (this.programs.some(({ endDateTime }) => new Date(endDateTime).getTime() <= endTime)) {
  244. return this.$confirm(
  245. '存在过期或将要过期(<=60s)节目,是否移除?',
  246. '提示',
  247. {
  248. type: 'warning',
  249. distinguishCancelAndClose: true,
  250. confirmButtonText: '移除',
  251. cancelButtonText: '保留'
  252. }
  253. ).then(() => {
  254. endTime = Date.now() + 60000
  255. this.scheduleOptions.programs = this.programs.filter(({ endDateTime }) => new Date(endDateTime).getTime() > endTime)
  256. if (this._compare(this.current, this._transform(endTime), false) <= 0) {
  257. this._calculate()
  258. }
  259. return this.programs
  260. }, action => {
  261. if (action === 'cancel') {
  262. return this.programs
  263. }
  264. return Promise.reject()
  265. })
  266. }
  267. return Promise.resolve(this.programs)
  268. },
  269. onSave () {
  270. this.fix().then(programs => {
  271. this.save(programs.map(({ id, name, startDateTime, endDateTime }) => {
  272. endDateTime = this._offsetDateTime(endDateTime, 1)
  273. return {
  274. programId: id,
  275. programName: name,
  276. startDateTime, endDateTime
  277. }
  278. })).then(() => {
  279. this.dirty = false
  280. })
  281. })
  282. },
  283. _transformPrograms (programs) {
  284. return programs.sort(this._sortPrograms).map(this._transformProgram)
  285. },
  286. _sortPrograms (a, b) {
  287. return new Date(a.startDateTime).getTime() - new Date(b.startDateTime).getTime()
  288. },
  289. _transformProgram ({ programId, programName, startDateTime, endDateTime }) {
  290. // 服务器保存的时间采用左闭右开原则
  291. return this._createProgram(programId, programName, startDateTime, this._offsetDateTime(endDateTime, -1))
  292. },
  293. _createProgram (id, name, startDateTime, endDateTime) {
  294. return {
  295. id, name, startDateTime, endDateTime,
  296. key: Math.random().toString(16).slice(2),
  297. days: this._getDays(startDateTime, endDateTime)
  298. }
  299. },
  300. _offsetDateTime (dateTime, val) {
  301. return parseTime(new Date(dateTime).getTime() + val * 1000, '{y}-{m}-{d} {h}:{i}:{s}')
  302. },
  303. _getDays (startDateTime, endDateTime) {
  304. const days = {}
  305. const { year: endYear, month: endMonth, day: endDay } = this._transform(endDateTime)
  306. const end = `${endYear}-${endMonth + 1}-${endDay}`
  307. let { year, month, day } = this._transform(startDateTime)
  308. let start = `${year}-${month + 1}-${day}`
  309. while (start !== end) {
  310. days[start] = 1
  311. ;({ year, month, day } = this._transform(new Date(year, month, day + 1)))
  312. start = `${year}-${month + 1}-${day}`
  313. }
  314. days[start] = 1
  315. return days
  316. },
  317. removeProgram ({ key, name }) {
  318. this.$confirm(
  319. `确定移除节目 ${name}`,
  320. '提示',
  321. {
  322. type: 'warning',
  323. confirmButtonText: '确定',
  324. cancelButtonText: '取消'
  325. }
  326. ).then(() => {
  327. this.dirty = true
  328. this._removeProgram(key)
  329. this._calculate()
  330. })
  331. },
  332. _removeProgram (key) {
  333. if (!key) {
  334. return
  335. }
  336. const index = this.programs.findIndex(program => program.key === key)
  337. this.programs.splice(index, 1)
  338. if (index === 0) {
  339. this._setMinDate()
  340. if (this._compare(this.current, this.min, false) < 0) {
  341. this.current = {
  342. year: this.min.year,
  343. month: this.min.month
  344. }
  345. }
  346. }
  347. },
  348. _setMinDate () {
  349. let minDate = this._transform(Date.now())
  350. if (this.programs.length) {
  351. const minProgramDate = this._transform(this.programs[0].startDateTime)
  352. if (this._compare(minDate, minProgramDate, false) > 0) {
  353. minDate = minProgramDate
  354. }
  355. }
  356. if (!this.min || this._compare(this.min, minDate) < 0) {
  357. this.min = minDate
  358. }
  359. },
  360. addProgram () {
  361. this.editProgram({})
  362. },
  363. addProgramByDay (day) {
  364. if (day.editable) {
  365. const { year, month } = this.current
  366. const date = day.value
  367. this.editProgram({
  368. startDateTime: `${year}-${(month + 1).toString().padStart(2, '0')}-${date} ${this._compare({ year, month, day: date }, this._transform(Date.now())) === 0 ? parseTime(new Date(), '{h}:{i}:{s}') : '00:00:00'}`,
  369. endDateTime: `${year}-${(month + 1).toString().padStart(2, '0')}-${date} 23:59:59`
  370. })
  371. }
  372. },
  373. editProgram (program) {
  374. if (this.editable) {
  375. this.program = program
  376. this.editing = true
  377. }
  378. },
  379. saveProgram () {
  380. const { id, name, startDateTime, endDateTime } = this.$refs.editor.getValue()
  381. if (!startDateTime || !endDateTime) {
  382. this.$message({
  383. type: 'warning',
  384. message: '请选择生效时间'
  385. })
  386. return
  387. }
  388. if (!id) {
  389. this.$message({
  390. type: 'warning',
  391. message: '请选择节目'
  392. })
  393. return
  394. }
  395. if (this.program.id &&
  396. id === this.program.id &&
  397. startDateTime === this.program.startDateTime &&
  398. endDateTime === this.program.endDateTime
  399. ) {
  400. this.handleCloseEditDialog()
  401. return
  402. }
  403. this._program = { id, name, startDateTime, endDateTime }
  404. if (this._checkConflict(this._program, this.program.key)) {
  405. this._saveProgram()
  406. }
  407. },
  408. _saveProgram () {
  409. this._removeProgram(this.program.key)
  410. this._mergeOrAdd()
  411. this.dirty = true
  412. this._calculate()
  413. this.handleCloseEditDialog()
  414. },
  415. _mergeOrAdd () {
  416. let program = this._program
  417. const timestamp = new Date(program.startDateTime).getTime()
  418. const insertIndex = this.programs.findIndex(item => new Date(item.startDateTime).getTime() > timestamp)
  419. let pre = null
  420. let next = null
  421. let add = true
  422. if (~insertIndex) {
  423. pre = this.programs[insertIndex - 1]
  424. next = this.programs[insertIndex]
  425. } else {
  426. pre = this.programs[this.programs.length - 1]
  427. }
  428. if (next && program.id === next.id && new Date(program.endDateTime).getTime() + 1000 === new Date(next.startDateTime).getTime()) {
  429. next.startDateTime = program.startDateTime
  430. program = next
  431. add = false
  432. }
  433. if (pre && program.id === pre.id && new Date(pre.endDateTime).getTime() + 1000 === new Date(program.startDateTime).getTime()) {
  434. pre.endDateTime = program.endDateTime
  435. this._removeProgram(program.key)
  436. program = pre
  437. add = false
  438. }
  439. program.key = Math.random().toString(16).slice(2)
  440. program.days = this._getDays(program.startDateTime, program.endDateTime)
  441. if (add) {
  442. if (next) {
  443. this.programs.splice(insertIndex, 0, program)
  444. } else {
  445. this.programs.push(program)
  446. }
  447. }
  448. },
  449. _checkConflict (program, key) {
  450. if (this.programs.length) {
  451. const cstartDateTime = new Date(program.startDateTime)
  452. const cendDateTime = new Date(program.endDateTime)
  453. this.conflicts = this.programs.filter(item => {
  454. const startDateTime = new Date(item.startDateTime)
  455. const endDateTime = new Date(item.endDateTime)
  456. return item.key !== key && !(cstartDateTime > endDateTime || cendDateTime < startDateTime)
  457. }).map(item => {
  458. const startDateTime = new Date(item.startDateTime)
  459. const endDateTime = new Date(item.endDateTime)
  460. return {
  461. target: item,
  462. key: item.key,
  463. start: cstartDateTime > startDateTime ? program.startDateTime : item.startDateTime,
  464. end: cendDateTime > endDateTime ? item.endDateTime : program.endDateTime
  465. }
  466. })
  467. if (this.conflicts.length) {
  468. this.conflicting = true
  469. return false
  470. }
  471. }
  472. return true
  473. },
  474. _coverConflicts () {
  475. const now = Date.now()
  476. this.conflicts.forEach(({ target, key, start, end }) => {
  477. const { startDateTime, endDateTime } = target
  478. if (new Date(endDateTime).getTime() <= now || startDateTime === start && endDateTime === end) {
  479. this._removeProgram(key)
  480. return
  481. }
  482. if (startDateTime === start) {
  483. target.startDateTime = this._offsetDateTime(end, 1)
  484. } else {
  485. if (endDateTime !== end) {
  486. const index = this.programs.findIndex(({ key }) => key === target.key)
  487. this.programs.splice(index + 1, 0, this._createProgram(target.id, target.name, this._offsetDateTime(end, 1), target.endDateTime))
  488. }
  489. target.endDateTime = this._offsetDateTime(start, -1)
  490. }
  491. target.days = this._getDays(target.startDateTime, target.endDateTime)
  492. })
  493. this.conflicting = false
  494. this._saveProgram()
  495. },
  496. _coverConflictsFull () {
  497. this.conflicts.forEach(({ key }) => this._removeProgram(key))
  498. this.conflicting = false
  499. this._saveProgram()
  500. },
  501. handleCloseEditDialog () {
  502. this.program = null
  503. this.editing = false
  504. },
  505. today () {
  506. const { year, month } = this._transform(Date.now())
  507. this.current = { year, month }
  508. this._calculate()
  509. },
  510. _transform (timestamp) {
  511. const date = new Date(timestamp)
  512. return {
  513. year: date.getFullYear(),
  514. month: date.getMonth(),
  515. day: date.getDate()
  516. }
  517. },
  518. _compare (a, b, full = true) {
  519. if (a.year === b.year) {
  520. if (full && a.month === b.month) {
  521. return a.day - b.day
  522. }
  523. return a.month - b.month
  524. }
  525. return a.year - b.year
  526. },
  527. _createItem (date, disabled) {
  528. const dateObj = this._transform(date)
  529. const invalid = this._compare(dateObj, this._transform(Date.now())) < 0
  530. const day = `${dateObj.year}-${dateObj.month + 1}-${dateObj.day}`
  531. return {
  532. value: dateObj.day,
  533. editable: this.editable && !disabled && !invalid,
  534. invalid,
  535. disabled,
  536. programs: this.programs.length ? this.programs.filter(program => {
  537. return program.days[day]
  538. }) : []
  539. }
  540. },
  541. _calculate () {
  542. const { year, month } = this.current
  543. const monthDays = this._getPresentMonthDays(year, month).concat(this._currentMonthDays(year, month))
  544. const diff = 42 - monthDays.length
  545. const dates = diff === 0 ? monthDays : monthDays.concat(this._getNextMonthDays(year, month, diff))
  546. const weeks = []
  547. let row
  548. for (let i = 0; i < dates.length; i++) {
  549. if (i % 7 === 0) {
  550. row = weeks.length
  551. weeks[row] = []
  552. }
  553. weeks[row][i % 7] = dates[i]
  554. }
  555. this.weeks = weeks
  556. },
  557. _currentMonthDays (year, month) {
  558. const dateArr = []
  559. const lastDay = new Date(year, month + 1, 0).getDate()
  560. for (let i = 1; i <= lastDay; i++) {
  561. dateArr.push(this._createItem(new Date(year, month, i)))
  562. }
  563. return dateArr
  564. },
  565. _getPresentMonthDays (year, month) {
  566. const dateArr = []
  567. const week = new Date(year, month, 1).getDay()
  568. const fill = week === 0 ? 7 : week
  569. for (let i = fill; i > 0; i--) {
  570. dateArr.push(this._createItem(new Date(year, month, 1 - i), true))
  571. }
  572. return dateArr
  573. },
  574. _getNextMonthDays (year, month, count) {
  575. const dateArr = []
  576. for (let i = 0; i < count; i++) {
  577. dateArr.push(this._createItem(new Date(year, month + 1, 1 + i), true))
  578. }
  579. return dateArr
  580. },
  581. _offsetMonth (offset) {
  582. if (offset < 0) {
  583. if (this.current.month === 0) {
  584. this.current.year -= 1
  585. this.current.month = 11
  586. } else {
  587. this.current.month -= 1
  588. }
  589. } else if (offset > 0) {
  590. if (this.current.month === 11) {
  591. this.current.year += 1
  592. this.current.month = 0
  593. } else {
  594. this.current.month += 1
  595. }
  596. }
  597. this._calculate()
  598. },
  599. toPresent () {
  600. if (this.canToPresent) {
  601. this._offsetMonth(-1)
  602. }
  603. },
  604. toNext () {
  605. if (this.canToNext) {
  606. this._offsetMonth(1)
  607. }
  608. }
  609. }
  610. }
  611. </script>
  612. <style lang="scss" scoped>
  613. .c-schedule-calendar {
  614. min-height: 200px;
  615. &__tip {
  616. color: $black;
  617. line-height: 1;
  618. }
  619. &__time {
  620. width: 100px;
  621. color: $black;
  622. font-size: 24px;
  623. font-weight: bold;
  624. text-align: center;
  625. }
  626. &__btn {
  627. color: $blue;
  628. font-size: 18px;
  629. font-weight: bold;
  630. visibility: hidden;
  631. &.show {
  632. visibility: visible;
  633. }
  634. }
  635. &__main {
  636. color: $black;
  637. font-size: 16px;
  638. }
  639. &__content {
  640. min-height: 0;
  641. border: 1px solid $gray--light;
  642. overflow-y: auto;
  643. }
  644. &__row + &__row {
  645. border-top: 1px solid $gray--light;
  646. }
  647. &__col {
  648. flex: 1 1 0;
  649. display: flex;
  650. min-width: 0;
  651. height: 108px;
  652. user-select: none;
  653. &.disabled {
  654. color: $gray;
  655. }
  656. &.invalid {
  657. background-color: lighten($gray--light, 4%);
  658. cursor: not-allowed;
  659. }
  660. &.day.enabled {
  661. cursor: pointer;
  662. &:hover {
  663. transition: background-color 0.4s;
  664. background-color: #f2f8fe;
  665. }
  666. }
  667. & + & {
  668. border-left: 1px solid $gray--light;
  669. }
  670. }
  671. &__header {
  672. height: 40px;
  673. font-weight: bold;
  674. text-align: center;
  675. }
  676. }
  677. .c-day-card {
  678. display: inline-flex;
  679. flex-direction: column;
  680. padding: 10px 10px 0;
  681. font-size: 14px;
  682. line-height: 1;
  683. text-align: left;
  684. &__day {
  685. align-self: flex-end;
  686. font-size: 16px;
  687. }
  688. &__program {
  689. max-width: 100%;
  690. margin-top: 8px;
  691. }
  692. &__count {
  693. align-self: flex-start;
  694. display: inline-block;
  695. padding: 6px 10px;
  696. margin-top: 10px;
  697. color: #fff;
  698. font-weight: bold;
  699. border-radius: 4px;
  700. background-color: #fb8885;
  701. }
  702. }
  703. </style>