You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

750 lines
24 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>