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.

498 lines
15 KiB

4 months ago
<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>
<view ref="grid" class="grid" :style="'height:' + gridHeightPx + 'rpx'">
<!-- 日期格子 -->
<view class="row" v-for="(row, rIdx) in weeks" :key="rIdx">
<view class="cell" v-for="cell in row" :key="cell.fullDate" @tap="onDayClick(cell.fullDate)">
<text class="date-num" :class="{ dim: !cell.inMonth }">{{ cell.date }}</text>
<view class="cell-events">
<view v-for="ev in eventsForDate(cell.fullDate)" :key="ev.id" class="event-chip" :class="'event-type-' + (ev.type || 'default')">
{{ 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" :class="'event-type-' + (seg.type || 'default')" @tap="onEdit(seg.id)">
{{ 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() {
// 6x7 网格
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 weeks = []
for (let w = 0; w < 6; 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 数值
return this.headerHeight + this.weekHeaderHeight + this.cellHeight * 6
},
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)
}
})
// 为每周的分段分配 lane避免重叠
const byWeek = {}
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)
}
})
})
// 计算样式以网格起点为基准
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 topRpx = this.headerHeight + this.weekHeaderHeight + (row * this.cellHeight) + this.dateNumberHeight + vOffset
seg._style = {
left: leftPct + '%',
width: widthPct + '%',
top: topRpx + 'rpx',
height: heightRpx + 'rpx'
}
})
return segs
}
},
methods: {
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)
return (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()
})
},
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);
margin: 20rpx;
padding: 10rpx 0 20rpx 0;
}
.calendar-header {
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 46px;
border-bottom: 1px solid #ededed;
}
.nav-btn {
width: 40px;
text-align: center;
font-size: 20px;
color: #666;
}
.month-text {
width: 140px;
text-align: center;
font-size: 15px;
color: #333;
}
.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;
padding: 6px 10px;
height: 32px;
box-sizing: border-box;
}
.weekday {
width: 14.2857%;
text-align: center;
font-size: 12px;
color: #666;
}
.grid {
position: relative;
}
.row {
display: flex;
flex-direction: row;
}
.cell {
width: 14.2857%;
height: 120rpx;
border-bottom: 1px solid #f5f5f5;
border-top: 1px solid #f5f5f5;
border-right: 1px solid #f5f5f5;
position: relative;
padding: 2px 2px 2px 2px;
}
.row .cell:first-child {
border-left: 1px solid #f5f5f5;
}
.date-num {
font-size: 12px;
font-weight: 600;
color: #333;
position: relative;
z-index: 3;
}
.date-num.dim {
color: #bfbfbf;
}
.cell-events {
position: relative;
z-index: 3;
}
.event-chip {
font-size: 11px;
line-height: 14px;
padding: 1px 3px;
margin: 1px 0;
color: #fff;
border-radius: 3px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.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: #409EFF; }
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 2;
}
.continuous-bar {
position: absolute;
pointer-events: auto;
z-index: 2;
color: #fff;
font-size: 11px;
line-height: 18px;
padding: 0 4px;
border-radius: 3px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
background: #409EFF;
}
.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: linear-gradient(90deg, #409EFF 0%, #337ecc 100%); }
</style>