|
|
<template>
|
|
|
<view class="calendar-grid">
|
|
|
<view class="calendar-header">
|
|
|
<view class="nav-btn" @tap="prevMonth">‹</view>
|
|
|
<text class="month-text">{{ displayYear }}年{{ displayMonthText }}月</text>
|
|
|
<view class="nav-btn" @tap="nextMonth">›</view>
|
|
|
<!-- <text class="back-today" @tap="backToday">今天</text> -->
|
|
|
</view>
|
|
|
|
|
|
<view class="weekdays">
|
|
|
<text v-for="(d, wi) in weekNames" :key="wi" class="weekday">{{ d }}</text>
|
|
|
</view>
|
|
|
<!-- :style="'height:' + gridHeightPx + 'rpx'" -->
|
|
|
<view ref="grid" class="grid">
|
|
|
<!-- 日期格子 -->
|
|
|
<view class="row" v-for="(row, rIdx) in weeks" :key="rIdx">
|
|
|
<view class="cell" v-for="cell in row" :key="cell.fullDate" :style="'height:'+cellHeight+'rpx'" @tap="onDayClick(cell.fullDate)">
|
|
|
<text class="date-num" :class="{ dim: !cell.inMonth }">{{ cell.date }}</text>
|
|
|
<view class="cell-events" :style="'padding-top:'+getCellPadding(cell.fullDate)+'rpx'">
|
|
|
<view
|
|
|
v-for="ev in eventsForDate(cell.fullDate)"
|
|
|
:key="ev.id"
|
|
|
class="event-chip"
|
|
|
:class="[
|
|
|
'event-type-' + (ev.type || 'default'),
|
|
|
{
|
|
|
'single-line-open': isSingleEvent(cell.fullDate) && !hasOtherEvents(cell.fullDate),
|
|
|
'has-other-events': hasOtherEvents(cell.fullDate),
|
|
|
'conflict': hasSpanConflict(cell.fullDate),
|
|
|
// 当采用底部定位布局时,禁用 has-multi-day 以避免 padding-top:10px !important 干扰
|
|
|
'has-multi-day': hasSpanConflict(cell.fullDate) && !hasMultiDayCover(cell.fullDate)
|
|
|
}
|
|
|
]"
|
|
|
:style="[
|
|
|
{ background: (ev && ev.color) ? ev.color : '#ddba99' },
|
|
|
// 冲突下移(较低优先级)
|
|
|
hasSpanConflict(cell.fullDate)
|
|
|
? { transform: 'translateY(10px)', paddingTop: '10px' }
|
|
|
: {},
|
|
|
// 有跨天覆盖或其他事件:固定到底部,单行,清除上内边距与位移
|
|
|
hasMultiDayCover(cell.fullDate)
|
|
|
? { position: 'absolute', bottom: '10px', left: 0, right: 0, paddingTop: '0px', transform: 'none', '-webkit-line-clamp': 1, 'line-clamp': 1 }
|
|
|
: {}
|
|
|
]"
|
|
|
@tap.stop="onEventClick(ev)"
|
|
|
>
|
|
|
{{ formatTitle(ev.title) }}
|
|
|
</view>
|
|
|
</view>
|
|
|
</view>
|
|
|
</view>
|
|
|
|
|
|
<!-- 跨天覆盖层 -->
|
|
|
<view class="overlay">
|
|
|
<view v-for="(seg, si) in continuousSegments" :key="si" class="continuous-bar" :style="'left:'+seg._style.left+';width:'+seg._style.width+';top:'+seg._style.top+';height:'+seg._style.height+';'+(seg._isOnlyOne ? '' : ('line-height:'+seg._style.height+';'))+(seg.color?('background:'+seg.color+';'):'background:#ddba99;')" :class="['event-type-' + (seg.type || 'default'), { 'nobreak': seg._isOnlyOne } ]" @tap.stop="onSegmentClick(seg)">
|
|
|
{{ formatTitle(seg.title) }}
|
|
|
</view>
|
|
|
</view>
|
|
|
</view>
|
|
|
</view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
export default {
|
|
|
name: 'CalendarGrid',
|
|
|
props: {
|
|
|
month: { // YYYY-MM
|
|
|
type: String,
|
|
|
required: true
|
|
|
},
|
|
|
|
|
|
events: { // [{ id, title, type, start_time, end_time }]
|
|
|
type: Array,
|
|
|
default: () => []
|
|
|
},
|
|
|
// 每行高度,单位 rpx
|
|
|
rowHeightRpx: {
|
|
|
type: Number,
|
|
|
default: 140
|
|
|
},
|
|
|
headerHeightRpx: {
|
|
|
type: Number,
|
|
|
default: 80
|
|
|
},
|
|
|
weekHeaderHeightRpx: {
|
|
|
type: Number,
|
|
|
default: 56
|
|
|
},
|
|
|
dateNumberHeightRpx: {
|
|
|
type: Number,
|
|
|
default: 48
|
|
|
},
|
|
|
barHeightRpx: {
|
|
|
type: Number,
|
|
|
default: 28
|
|
|
},
|
|
|
barSpacingRpx: {
|
|
|
type: Number,
|
|
|
default: 6
|
|
|
}
|
|
|
},
|
|
|
data() {
|
|
|
return {
|
|
|
FIRST_DOW: 0, // 0=Sunday
|
|
|
// rpx 单位(仅存数值,使用时拼接 rpx)
|
|
|
cellHeight: this.rowHeightRpx,
|
|
|
headerHeight: this.headerHeightRpx,
|
|
|
weekHeaderHeight: this.weekHeaderHeightRpx,
|
|
|
dateNumberHeight: this.dateNumberHeightRpx,
|
|
|
barHeight: this.barHeightRpx,
|
|
|
barSpacing: this.barSpacingRpx
|
|
|
}
|
|
|
},
|
|
|
watch: {
|
|
|
rowHeightRpx(val) {
|
|
|
this.cellHeight = Number(val) || this.cellHeight
|
|
|
},
|
|
|
headerHeightRpx(val) {
|
|
|
this.headerHeight = Number(val) || this.headerHeight
|
|
|
},
|
|
|
weekHeaderHeightRpx(val) {
|
|
|
this.weekHeaderHeight = Number(val) || this.weekHeaderHeight
|
|
|
},
|
|
|
dateNumberHeightRpx(val) {
|
|
|
this.dateNumberHeight = Number(val) || this.dateNumberHeight
|
|
|
},
|
|
|
barHeightRpx(val) {
|
|
|
this.barHeight = Number(val) || this.barHeight
|
|
|
},
|
|
|
barSpacingRpx(val) {
|
|
|
this.barSpacing = Number(val) || this.barSpacing
|
|
|
}
|
|
|
},
|
|
|
computed: {
|
|
|
displayMonthText() {
|
|
|
const m = this.displayMonth
|
|
|
return (m < 10 ? ('0' + m) : '' + m)
|
|
|
},
|
|
|
displayYear() {
|
|
|
const [y] = this.month.split('-').map(Number)
|
|
|
return y
|
|
|
},
|
|
|
displayMonth() {
|
|
|
const [, m] = this.month.split('-').map(Number)
|
|
|
return m
|
|
|
},
|
|
|
baseDate() {
|
|
|
// 兼容 month 传入为空或异常
|
|
|
if (!this.month || typeof this.month !== 'string') {
|
|
|
const t = new Date()
|
|
|
return new Date(t.getFullYear(), t.getMonth(), 1)
|
|
|
}
|
|
|
const [yStr, mStr] = this.month.split('-')
|
|
|
const y = parseInt(yStr, 10)
|
|
|
const m = parseInt(mStr, 10)
|
|
|
if (!y || !m) {
|
|
|
const t = new Date()
|
|
|
return new Date(t.getFullYear(), t.getMonth(), 1)
|
|
|
}
|
|
|
return new Date(y, m - 1, 1)
|
|
|
},
|
|
|
weekNames() {
|
|
|
return ['日','一','二','三','四','五','六']
|
|
|
},
|
|
|
weeks() {
|
|
|
// 动态计算需要的行数
|
|
|
const firstDay = new Date(this.baseDate)
|
|
|
if (!(firstDay instanceof Date) || isNaN(firstDay.getTime())) return []
|
|
|
const startDow = firstDay.getDay()
|
|
|
const offset = (startDow - this.FIRST_DOW + 7) % 7
|
|
|
const gridStart = new Date(firstDay)
|
|
|
gridStart.setDate(1 - offset)
|
|
|
|
|
|
// 计算当月最后一天
|
|
|
const lastDay = new Date(this.baseDate.getFullYear(), this.baseDate.getMonth() + 1, 0)
|
|
|
const lastDayOfMonth = lastDay.getDate()
|
|
|
|
|
|
// 计算需要的行数
|
|
|
const totalDays = offset + lastDayOfMonth
|
|
|
const neededWeeks = Math.ceil(totalDays / 7)
|
|
|
|
|
|
const weeks = []
|
|
|
for (let w = 0; w < neededWeeks; w += 1) {
|
|
|
const row = []
|
|
|
for (let d = 0; d < 7; d += 1) {
|
|
|
const cur = new Date(gridStart)
|
|
|
cur.setDate(gridStart.getDate() + (w * 7 + d))
|
|
|
row.push({
|
|
|
date: cur.getDate(),
|
|
|
month: cur.getMonth() + 1,
|
|
|
year: cur.getFullYear(),
|
|
|
inMonth: cur.getMonth() === this.baseDate.getMonth(),
|
|
|
fullDate: `${cur.getFullYear()}-${this.pad2(cur.getMonth()+1)}-${this.pad2(cur.getDate())}`
|
|
|
})
|
|
|
}
|
|
|
weeks.push(row)
|
|
|
}
|
|
|
return weeks
|
|
|
},
|
|
|
gridHeightPx() {
|
|
|
// 返回 rpx 数值,根据实际行数计算
|
|
|
const actualWeeks = this.weeks.length
|
|
|
return this.headerHeight + this.weekHeaderHeight + this.cellHeight * actualWeeks
|
|
|
},
|
|
|
continuousSegments() {
|
|
|
// 拆分跨天事件为每周分段
|
|
|
const events = (this.events || []).filter(ev => ev && ev.start_time)
|
|
|
const y = this.displayYear
|
|
|
const m = this.displayMonth - 1
|
|
|
const monthStart = new Date(y, m, 1)
|
|
|
const monthEnd = new Date(y, m + 1, 0)
|
|
|
|
|
|
const multiDay = events.filter(ev => {
|
|
|
if (!ev.end_time) return false
|
|
|
const s = this.parseDateTime(ev.start_time)
|
|
|
const e = this.parseDateTime(ev.end_time)
|
|
|
return s.toDateString() !== e.toDateString()
|
|
|
})
|
|
|
|
|
|
const segs = []
|
|
|
|
|
|
const getWeekStart = (date) => {
|
|
|
const d = new Date(date)
|
|
|
const jsDow = d.getDay()
|
|
|
const off = (jsDow - this.FIRST_DOW + 7) % 7
|
|
|
d.setDate(d.getDate() - off)
|
|
|
d.setHours(0,0,0,0)
|
|
|
return d
|
|
|
}
|
|
|
|
|
|
multiDay.forEach(ev => {
|
|
|
const s = this.parseDateTime(ev.start_time)
|
|
|
const e = this.parseDateTime(ev.end_time)
|
|
|
if (e < monthStart || s > monthEnd) return
|
|
|
const clampedStart = s < monthStart ? monthStart : s
|
|
|
const clampedEnd = e > monthEnd ? monthEnd : e
|
|
|
let cursor = getWeekStart(clampedStart)
|
|
|
while (cursor <= clampedEnd) {
|
|
|
const weekStart = new Date(cursor)
|
|
|
const weekEnd = new Date(cursor)
|
|
|
weekEnd.setDate(weekEnd.getDate() + 6)
|
|
|
|
|
|
const segStart = clampedStart > weekStart ? clampedStart : weekStart
|
|
|
const segEnd = clampedEnd < weekEnd ? clampedEnd : weekEnd
|
|
|
if (segStart <= segEnd) {
|
|
|
const startCol = (segStart.getDay() - this.FIRST_DOW + 7) % 7
|
|
|
const endCol = (segEnd.getDay() - this.FIRST_DOW + 7) % 7
|
|
|
const spanCols = endCol - startCol + 1
|
|
|
segs.push({
|
|
|
...ev,
|
|
|
weekStartISO: weekStart.toISOString(),
|
|
|
// 保留本地毫秒时间,避免 ISO/时区转换带来的日期偏移
|
|
|
segStartMs: segStart.getTime(),
|
|
|
segEndMs: segEnd.getTime(),
|
|
|
startCol,
|
|
|
endCol,
|
|
|
spanCols
|
|
|
})
|
|
|
}
|
|
|
cursor.setDate(cursor.getDate() + 7)
|
|
|
}
|
|
|
})
|
|
|
|
|
|
// 统计跨天分段对每日的覆盖数量
|
|
|
const segCountByDate = {}
|
|
|
const addDateKey = (ms) => {
|
|
|
const d = new Date(ms)
|
|
|
d.setHours(0,0,0,0)
|
|
|
const key = `${d.getFullYear()}-${this.pad2(d.getMonth()+1)}-${this.pad2(d.getDate())}`
|
|
|
segCountByDate[key] = (segCountByDate[key] || 0) + 1
|
|
|
}
|
|
|
segs.forEach(seg => {
|
|
|
for (let t = seg.segStartMs; t <= seg.segEndMs; t += 86400000) {
|
|
|
addDateKey(t)
|
|
|
}
|
|
|
})
|
|
|
|
|
|
// 为每周的分段分配 lane,避免重叠
|
|
|
const byWeek = {}
|
|
|
const laneCountByDate = {}
|
|
|
segs.forEach(s => {
|
|
|
const key = s.weekStartISO
|
|
|
if (!byWeek[key]) byWeek[key] = []
|
|
|
byWeek[key].push(s)
|
|
|
})
|
|
|
|
|
|
Object.values(byWeek).forEach(arr => {
|
|
|
arr.sort((a,b) => (a.startCol - b.startCol) || (b.spanCols - a.spanCols))
|
|
|
const laneEnd = []
|
|
|
arr.forEach(seg => {
|
|
|
let placed = false
|
|
|
for (let i=0;i<laneEnd.length;i+=1) {
|
|
|
if (laneEnd[i] < seg.startCol) {
|
|
|
seg.laneIndex = i
|
|
|
laneEnd[i] = seg.endCol
|
|
|
placed = true
|
|
|
break
|
|
|
}
|
|
|
}
|
|
|
if (!placed) {
|
|
|
seg.laneIndex = laneEnd.length
|
|
|
laneEnd.push(seg.endCol)
|
|
|
}
|
|
|
// 记录该分段覆盖到的每日所需预留层数
|
|
|
for (let t = seg.segStartMs; t <= seg.segEndMs; t += 86400000) {
|
|
|
const d = new Date(t)
|
|
|
const key = `${d.getFullYear()}-${this.pad2(d.getMonth()+1)}-${this.pad2(d.getDate())}`
|
|
|
laneCountByDate[key] = Math.max(laneCountByDate[key] || 0, seg.laneIndex + 1)
|
|
|
}
|
|
|
})
|
|
|
})
|
|
|
|
|
|
// 计算样式以网格起点为基准
|
|
|
const y2 = this.displayYear
|
|
|
const m2 = this.displayMonth - 1
|
|
|
const firstDay = new Date(y2, m2, 1)
|
|
|
const startDow = firstDay.getDay()
|
|
|
const offset = (startDow - this.FIRST_DOW + 7) % 7
|
|
|
const gridStart = new Date(firstDay)
|
|
|
gridStart.setDate(1 - offset)
|
|
|
gridStart.setHours(0,0,0,0)
|
|
|
const msPerDay = 86400000
|
|
|
const cellWidthPct = 100 / 7
|
|
|
const heightRpx = this.barHeight
|
|
|
|
|
|
segs.forEach(seg => {
|
|
|
const segStart = new Date(seg.segStartMs)
|
|
|
|
|
|
// 基于 weeks 网格直接定位行列,避免周起始偏差
|
|
|
const startStr = `${segStart.getFullYear()}-${this.pad2(segStart.getMonth()+1)}-${this.pad2(segStart.getDate())}`
|
|
|
let row = 0
|
|
|
let col = seg.startCol
|
|
|
outer: for (let r = 0; r < this.weeks.length; r += 1) {
|
|
|
const rowArr = this.weeks[r]
|
|
|
for (let c = 0; c < rowArr.length; c += 1) {
|
|
|
if (rowArr[c].fullDate === startStr) {
|
|
|
row = r
|
|
|
col = c
|
|
|
break outer
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const leftPct = col * cellWidthPct
|
|
|
const widthPct = seg.spanCols * cellWidthPct
|
|
|
const vOffset = (seg.laneIndex || 0) * (this.barHeight + this.barSpacing)
|
|
|
const innerTopPadding = 8 // 额外内边距,避免贴近格子顶部
|
|
|
const topRpx = (row * this.cellHeight) + this.dateNumberHeight + innerTopPadding + vOffset - 3
|
|
|
|
|
|
// 确保事件不会超出日历边界
|
|
|
const maxLeftPct = 100 - widthPct
|
|
|
const finalLeftPct = Math.min(leftPct, maxLeftPct)
|
|
|
|
|
|
seg._style = {
|
|
|
left: finalLeftPct + '%',
|
|
|
width: widthPct + '%',
|
|
|
top: topRpx + 'rpx',
|
|
|
height: heightRpx + 'rpx'
|
|
|
}
|
|
|
|
|
|
// 判断该跨天分段在其覆盖的日期是否独占(无单天事件、无其它跨天分段)
|
|
|
let onlyOne = true
|
|
|
for (let t = seg.segStartMs; t <= seg.segEndMs; t += 86400000) {
|
|
|
const d = new Date(t)
|
|
|
const key = `${d.getFullYear()}-${this.pad2(d.getMonth()+1)}-${this.pad2(d.getDate())}`
|
|
|
const segCnt = segCountByDate[key] || 0
|
|
|
const singleCnt = (this.eventsForDate(key) || []).length // 单天事件数量
|
|
|
if (!(segCnt === 1 && singleCnt === 0)) { onlyOne = false; break }
|
|
|
}
|
|
|
seg._isOnlyOne = onlyOne
|
|
|
})
|
|
|
|
|
|
// 暴露给实例:为单日事件预留空间 & 判定与跨天冲突
|
|
|
this.laneCountByDate = laneCountByDate
|
|
|
this.segCountByDate = segCountByDate
|
|
|
|
|
|
// 调试信息
|
|
|
console.log('segCountByDate 统计结果:', segCountByDate)
|
|
|
console.log('所有跨天分段:', segs.map(seg => ({
|
|
|
id: seg.id,
|
|
|
title: seg.title,
|
|
|
start: new Date(seg.segStartMs).toISOString().split('T')[0],
|
|
|
end: new Date(seg.segEndMs).toISOString().split('T')[0]
|
|
|
})))
|
|
|
|
|
|
return segs
|
|
|
}
|
|
|
},
|
|
|
methods: {
|
|
|
getCellPadding(fullDate) {
|
|
|
// 根据覆盖该日期的跨天事件层数,为单日事件预留顶部空间,避免与跨天条重叠
|
|
|
const lanes = this.laneCountByDate && this.laneCountByDate[fullDate] ? this.laneCountByDate[fullDate] : 0
|
|
|
if (!lanes) return 0
|
|
|
|
|
|
// 如果有冲突(既有跨天事件又有单天事件),增加额外的空间
|
|
|
const hasConflict = this.hasSpanConflict(fullDate)
|
|
|
const basePadding = lanes * (this.barHeight + this.barSpacing)
|
|
|
const extraPadding = hasConflict ? 40 : 0 // 额外增加40rpx的空间
|
|
|
|
|
|
return basePadding + extraPadding
|
|
|
},
|
|
|
isSingleEvent(fullDate) {
|
|
|
try {
|
|
|
const list = this.eventsForDate(fullDate) || []
|
|
|
return list.length === 1
|
|
|
} catch (_) {
|
|
|
return false
|
|
|
}
|
|
|
},
|
|
|
hasSpanConflict(fullDate) {
|
|
|
// 有跨天覆盖且该日也有单日事件
|
|
|
const hasMulti = !!(this.segCountByDate && this.segCountByDate[fullDate] > 0)
|
|
|
const hasSingle = (this.eventsForDate(fullDate) || []).length > 0
|
|
|
const result = hasMulti && hasSingle
|
|
|
|
|
|
return result
|
|
|
},
|
|
|
hasMultiDayCover(fullDate) {
|
|
|
// 直接基于当前计算出的连续分段判断该日期是否被任意跨天分段覆盖
|
|
|
try {
|
|
|
const target = new Date(fullDate)
|
|
|
target.setHours(0,0,0,0)
|
|
|
const tMs = target.getTime()
|
|
|
const segs = this.continuousSegments || []
|
|
|
for (let i = 0; i < segs.length; i += 1) {
|
|
|
const s = segs[i]
|
|
|
if (tMs >= s.segStartMs && tMs <= s.segEndMs) return true
|
|
|
}
|
|
|
return false
|
|
|
} catch (_) {
|
|
|
return false
|
|
|
}
|
|
|
},
|
|
|
hasOtherEvents(fullDate) {
|
|
|
// 判断该日期是否有其他事件(包括跨天事件)
|
|
|
const singleEvents = this.eventsForDate(fullDate) || []
|
|
|
const hasMultiDay = !!(this.segCountByDate && this.segCountByDate[fullDate] > 0)
|
|
|
const result = singleEvents.length > 1 || hasMultiDay
|
|
|
|
|
|
// 调试信息
|
|
|
if (fullDate.includes('25')) {
|
|
|
console.log(`日期 ${fullDate} 检查其他事件:`, {
|
|
|
singleEvents: singleEvents.length,
|
|
|
hasMultiDay,
|
|
|
segCount: this.segCountByDate ? this.segCountByDate[fullDate] : 'undefined',
|
|
|
result
|
|
|
})
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
},
|
|
|
onEventClick(ev) {
|
|
|
this.$emit('eventClick', ev)
|
|
|
},
|
|
|
onSegmentClick(seg) {
|
|
|
this.$emit('eventClick', seg)
|
|
|
this.$emit('edit', seg.id)
|
|
|
},
|
|
|
formatTitle(title) {
|
|
|
const raw = (title == null ? '' : String(title)).trim()
|
|
|
if (!raw) return ''
|
|
|
// 存在管道分隔(|或|):取最后一段,再去掉右侧附加(- 第二模块)
|
|
|
if (raw.includes('|') || raw.includes('|')) {
|
|
|
const pipeParts = raw.split(/[||]/).map(s => s.trim()).filter(Boolean)
|
|
|
const afterPipe = pipeParts.length ? pipeParts[pipeParts.length - 1] : raw
|
|
|
const core = afterPipe.split(/\s*[-—–-]\s*/)[0]
|
|
|
return core.trim()
|
|
|
}
|
|
|
// 没有管道,仅有破折号分隔:
|
|
|
const dashParts = raw.split(/\s*[-—–-]\s*/).map(s => s.trim()).filter(Boolean)
|
|
|
if (dashParts.length >= 3) {
|
|
|
// 典型:前缀 - 主体 - 尾巴 → 取中间段
|
|
|
return dashParts[Math.floor(dashParts.length / 2)]
|
|
|
}
|
|
|
if (dashParts.length === 2) {
|
|
|
// 只有两段:取更长的一段作为主体
|
|
|
return dashParts[0].length >= dashParts[1].length ? dashParts[0] : dashParts[1]
|
|
|
}
|
|
|
return raw
|
|
|
},
|
|
|
getEventChipStyle(ev) {
|
|
|
if (!ev) return {}
|
|
|
const custom = ev.color || ev.bg_color || ev.background
|
|
|
if (custom) return { background: custom, backgroundColor: custom }
|
|
|
return {}
|
|
|
},
|
|
|
getSegmentStyle(seg) {
|
|
|
if (!seg || !seg._style) return {}
|
|
|
const base = {
|
|
|
left: seg._style.left,
|
|
|
width: seg._style.width,
|
|
|
top: seg._style.top,
|
|
|
height: seg._style.height
|
|
|
}
|
|
|
const custom = seg.color || seg.bg_color || seg.background
|
|
|
if (custom) {
|
|
|
base.background = custom
|
|
|
base.backgroundColor = custom
|
|
|
}
|
|
|
return base
|
|
|
},
|
|
|
prevMonth() {
|
|
|
const d = new Date(this.baseDate)
|
|
|
d.setMonth(d.getMonth() - 1)
|
|
|
this.$emit('monthChange', { year: d.getFullYear(), month: d.getMonth() + 1 })
|
|
|
},
|
|
|
nextMonth() {
|
|
|
const d = new Date(this.baseDate)
|
|
|
d.setMonth(d.getMonth() + 1)
|
|
|
this.$emit('monthChange', { year: d.getFullYear(), month: d.getMonth() + 1 })
|
|
|
},
|
|
|
backToday() {
|
|
|
const t = new Date()
|
|
|
const isSameMonth = t.getFullYear() === this.displayYear && (t.getMonth()+1) === this.displayMonth
|
|
|
if (!isSameMonth) {
|
|
|
this.$emit('monthChange', { year: t.getFullYear(), month: t.getMonth() + 1 })
|
|
|
}
|
|
|
// 同时触发当天点击
|
|
|
const full = `${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,'0')}-${String(t.getDate()).padStart(2,'0')}`
|
|
|
this.onDayClick(full)
|
|
|
},
|
|
|
onDayClick(fullDate) {
|
|
|
this.$emit('dayClick', { fulldate: fullDate })
|
|
|
},
|
|
|
onEdit(id) {
|
|
|
// 向外抛出用于编辑的事件(如有需要)
|
|
|
this.$emit('edit', id)
|
|
|
},
|
|
|
eventsForDate(fullDate) {
|
|
|
const d0 = new Date(fullDate)
|
|
|
d0.setHours(0,0,0,0)
|
|
|
const result = (this.events || []).filter(ev => {
|
|
|
if (!ev || !ev.start_time) return false
|
|
|
const s = this.parseDateTime(ev.start_time)
|
|
|
const e = ev.end_time ? this.parseDateTime(ev.end_time) : this.parseDateTime(ev.start_time)
|
|
|
s.setHours(0,0,0,0); e.setHours(0,0,0,0)
|
|
|
// 单天事件才在格子里显示,跨天事件走覆盖层
|
|
|
const isMulti = ev.end_time && (s.getTime() !== e.getTime())
|
|
|
if (isMulti) return false
|
|
|
return d0.getTime() === s.getTime()
|
|
|
})
|
|
|
return result
|
|
|
},
|
|
|
pad2(n) {
|
|
|
n = Number(n) || 0
|
|
|
return n < 10 ? ('0' + n) : String(n)
|
|
|
},
|
|
|
parseDateTime(dateTimeStr) {
|
|
|
if (!dateTimeStr) return null
|
|
|
const [datePart, timePart = '00:00:00'] = String(dateTimeStr).trim().split(/[T\s]+/)
|
|
|
const [y, m, d] = datePart.split('-').map(n => parseInt(n, 10))
|
|
|
const [hh = 0, mm = 0, ss = 0] = timePart.split(':').map(n => parseInt(n, 10))
|
|
|
// 以本地时间构建,避免不同时区解析成前一天/后一天
|
|
|
return new Date(y, (m || 1) - 1, d || 1, hh || 0, mm || 0, ss || 0)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
.calendar-grid {
|
|
|
background: #fff;
|
|
|
/* border-radius: 18rpx; */
|
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
|
border-radius: 0 0 20rpx 20rpx;
|
|
|
/* margin: 20rpx;
|
|
|
padding: 10rpx 0 20rpx 0; */
|
|
|
}
|
|
|
.calendar-header {
|
|
|
position: relative;
|
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
|
align-items: center;
|
|
|
height: 46px;
|
|
|
background:#eaf3fb;
|
|
|
color:#0d0398;
|
|
|
font-size:34rpx;
|
|
|
}
|
|
|
.nav-btn {
|
|
|
width: 40px;
|
|
|
text-align: center;
|
|
|
font-size: 50rpx;
|
|
|
color: #333;
|
|
|
}
|
|
|
.month-text {
|
|
|
width: 140px;
|
|
|
text-align: center;
|
|
|
font-size: 34rpx;
|
|
|
color:#0d0398;
|
|
|
}
|
|
|
.back-today {
|
|
|
position: absolute;
|
|
|
right: 8px;
|
|
|
top: 8px;
|
|
|
padding: 4px 8px;
|
|
|
border-radius: 12px;
|
|
|
font-size: 12px;
|
|
|
color: #333;
|
|
|
background: #f1f1f1;
|
|
|
}
|
|
|
.weekdays {
|
|
|
display: flex;
|
|
|
flex-direction: row;
|
|
|
justify-content: space-between;
|
|
|
height: 40px;
|
|
|
box-sizing: border-box;
|
|
|
color: #0d0398;
|
|
|
font-size: 28rpx;
|
|
|
}
|
|
|
.weekday {
|
|
|
width: 14.2857%;
|
|
|
text-align: center;
|
|
|
font-size: 28rpx;
|
|
|
line-height: 40px;
|
|
|
color: #0d0398;
|
|
|
border-right: 1px solid #f2eae2;
|
|
|
}
|
|
|
.weekday:last-child {
|
|
|
border-right:none
|
|
|
}
|
|
|
.grid {
|
|
|
position: relative;
|
|
|
}
|
|
|
.row {
|
|
|
display: flex;
|
|
|
flex-direction: row;
|
|
|
}
|
|
|
.cell {
|
|
|
width: 14.2857%;
|
|
|
height: 120rpx;
|
|
|
border-top: 1px solid #f2eae2;
|
|
|
border-right: 1px solid #f2eae2;
|
|
|
position: relative;
|
|
|
padding: 2px 2px 2px 2px;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
align-items: center;
|
|
|
justify-content: flex-start;
|
|
|
}
|
|
|
.row .cell:last-child {
|
|
|
border-right:none
|
|
|
}
|
|
|
.date-num {
|
|
|
font-size: 28rpx;
|
|
|
font-weight: 600;
|
|
|
color: #333;
|
|
|
position: relative;
|
|
|
text-align: center;
|
|
|
width: 100%;
|
|
|
margin-bottom: 4px;
|
|
|
}
|
|
|
.date-num.dim {
|
|
|
color: #bfbfbf;
|
|
|
}
|
|
|
.cell-events {
|
|
|
position: relative;
|
|
|
width: 100%;
|
|
|
overflow: hidden;
|
|
|
}
|
|
|
.event-chip {
|
|
|
font-size: 11px;
|
|
|
line-height: 14px;
|
|
|
padding: 2rpx 10rpx;
|
|
|
margin: 1px 0;
|
|
|
color: #fff;
|
|
|
border-radius: 16rpx;
|
|
|
overflow: hidden;
|
|
|
max-width: 100%;
|
|
|
box-sizing: border-box;
|
|
|
display: -webkit-box;
|
|
|
-webkit-line-clamp: 1;
|
|
|
-webkit-box-orient: vertical;
|
|
|
}
|
|
|
.event-chip.event-type-1 { background: #67C23A; }
|
|
|
.event-chip.event-type-2 { background: #409EFF; }
|
|
|
.event-chip.event-type-3 { background: #E6A23C; }
|
|
|
.event-chip.event-type-4 { background: #F56C6C; }
|
|
|
.event-chip.event-type-5 { background: #909399; }
|
|
|
.event-chip.event-type-default { background: #ddba99; }
|
|
|
/* 当天只有一条单天事件时,允许换行不省略 */
|
|
|
.event-chip.single-line-open {
|
|
|
-webkit-line-clamp: 3; /* 最多三行 */
|
|
|
}
|
|
|
|
|
|
/* 当天有其他事件时(多条单天事件或跨天事件),单条数据定位在底部 */
|
|
|
.event-chip.single-line-open.has-other-events {
|
|
|
-webkit-line-clamp: 1; /* 强制一行 */
|
|
|
position: absolute;
|
|
|
bottom: 0;
|
|
|
left: 0;
|
|
|
right: 0;
|
|
|
}
|
|
|
/* 与跨天冲突时,单日事件强制一行省略 */
|
|
|
.event-chip.conflict {
|
|
|
-webkit-line-clamp: 1 !important;
|
|
|
}
|
|
|
|
|
|
/* 当有跨天事件时,单天事件需要更多上边距 */
|
|
|
.event-chip.has-multi-day {
|
|
|
padding-top: 10px !important;
|
|
|
}
|
|
|
|
|
|
.overlay {
|
|
|
position: absolute;
|
|
|
top: 0;
|
|
|
left: 0;
|
|
|
right: 0;
|
|
|
bottom: 0;
|
|
|
pointer-events: none;
|
|
|
}
|
|
|
.continuous-bar {
|
|
|
position: absolute;
|
|
|
pointer-events: auto;
|
|
|
color: #fff;
|
|
|
font-size: 11px;
|
|
|
line-height: 18px;
|
|
|
padding: 0rpx 10rpx;
|
|
|
border-radius: 16rpx;
|
|
|
overflow: hidden;
|
|
|
white-space: nowrap;
|
|
|
text-overflow: ellipsis;
|
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
|
background: #ddba99;
|
|
|
text-align: center;
|
|
|
}
|
|
|
.continuous-bar.event-type-1 { background: linear-gradient(90deg, #67C23A 0%, #5CB85C 100%); }
|
|
|
.continuous-bar.event-type-2 { background: linear-gradient(90deg, #409EFF 0%, #337ecc 100%); }
|
|
|
.continuous-bar.event-type-3 { background: linear-gradient(90deg, #E6A23C 0%, #D4952B 100%); }
|
|
|
.continuous-bar.event-type-4 { background: linear-gradient(90deg, #F56C6C 0%, #E85555 100%); }
|
|
|
.continuous-bar.event-type-5 { background: linear-gradient(90deg, #909399 0%, #73767A 100%); }
|
|
|
.continuous-bar.event-type-default { background: #ddba99; }
|
|
|
/* 跨天事件在整段期间独占时不省略,允许换行 */
|
|
|
.continuous-bar.nobreak {
|
|
|
white-space: normal;
|
|
|
word-break: break-all;
|
|
|
text-overflow: ellipsis;
|
|
|
height: auto !important; /* 允许多行内容自适应高度 */
|
|
|
line-height: 1.4;
|
|
|
padding-top: 6rpx;
|
|
|
padding-bottom: 6rpx;
|
|
|
display: -webkit-box;
|
|
|
-webkit-line-clamp: 3; /* 最多三行 */
|
|
|
-webkit-box-orient: vertical;
|
|
|
overflow: hidden;
|
|
|
}
|
|
|
</style>
|
|
|
|
|
|
|