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

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