301 lines
8.3 KiB
Vue
301 lines
8.3 KiB
Vue
|
<template>
|
|||
|
<view class="calendar-container">
|
|||
|
<!-- 展开/收起按钮,可以外部隐藏 -->
|
|||
|
<view class="calendar-header">
|
|||
|
<button size="mini" @click="toggleMode">
|
|||
|
{{ mode === 'month' ? '收起为周' : '展开为月' }}
|
|||
|
</button>
|
|||
|
<text>{{ displayTitle }}</text>
|
|||
|
</view>
|
|||
|
|
|||
|
<view
|
|||
|
class="calendar-content"
|
|||
|
@touchstart="onTouchStart"
|
|||
|
@touchmove="onTouchMove"
|
|||
|
@touchend="onTouchEnd"
|
|||
|
:style="{ height: mode === 'month' ? '450rpx' : '120rpx', transition: 'height 0.3s' }"
|
|||
|
>
|
|||
|
<!-- 星期栏 -->
|
|||
|
<view class="calendar-week">
|
|||
|
<text v-for="(w, i) in weeks" :key="i">{{ w }}</text>
|
|||
|
</view>
|
|||
|
|
|||
|
<!-- 日期 -->
|
|||
|
<view class="calendar-days">
|
|||
|
<view
|
|||
|
v-for="(item, i) in renderDays"
|
|||
|
:key="i"
|
|||
|
:class="{
|
|||
|
'calendar-day': true,
|
|||
|
'today': item.isToday,
|
|||
|
'selected': isSelected(item),
|
|||
|
'other-month': item.otherMonth
|
|||
|
}"
|
|||
|
@click="selectDate(item)"
|
|||
|
>
|
|||
|
{{ item.day || '' }}
|
|||
|
</view>
|
|||
|
</view>
|
|||
|
</view>
|
|||
|
</view>
|
|||
|
</template>
|
|||
|
|
|||
|
<script>
|
|||
|
export default {
|
|||
|
name: 'CustomCalendar',
|
|||
|
props: {
|
|||
|
initialMode: { type: String, default: 'month' }, // 'month' or 'week'
|
|||
|
initialDate: { type: String, default: '' },
|
|||
|
allowWeekSwitch: { type: Boolean, default: true } // 控制是否允许上下滑切换
|
|||
|
},
|
|||
|
data() {
|
|||
|
const today = new Date()
|
|||
|
return {
|
|||
|
mode: this.initialMode,
|
|||
|
selected: this.initialDate || this.formatDate(today),
|
|||
|
weeks: ['日', '一', '二', '三', '四', '五', '六'],
|
|||
|
renderDays: [],
|
|||
|
touchStartX: 0,
|
|||
|
touchStartY: 0,
|
|||
|
touchMoveX: 0,
|
|||
|
touchMoveY: 0,
|
|||
|
anchorDate: new Date() // 当前视图基准日期
|
|||
|
}
|
|||
|
},
|
|||
|
computed: {
|
|||
|
displayTitle() {
|
|||
|
return `${this.anchorDate.getFullYear()}年${this.anchorDate.getMonth() + 1}月`
|
|||
|
}
|
|||
|
},
|
|||
|
watch: {
|
|||
|
mode() {
|
|||
|
this.generateRenderDays()
|
|||
|
},
|
|||
|
selected() {
|
|||
|
this.generateRenderDays()
|
|||
|
}
|
|||
|
},
|
|||
|
mounted() {
|
|||
|
this.anchorDate = this.safeDate(this.selected)
|
|||
|
this.generateRenderDays()
|
|||
|
},
|
|||
|
methods: {
|
|||
|
// =================== 日期工具 ===================
|
|||
|
formatDate(date) {
|
|||
|
const y = date.getFullYear()
|
|||
|
const m = date.getMonth() + 1
|
|||
|
const d = date.getDate()
|
|||
|
return `${y}-${m < 10 ? '0' + m : m}-${d < 10 ? '0' + d : d}`
|
|||
|
},
|
|||
|
safeDate(date) {
|
|||
|
return new Date(date)
|
|||
|
},
|
|||
|
startOfMonth(date) {
|
|||
|
const d = this.safeDate(date)
|
|||
|
d.setDate(1)
|
|||
|
return d
|
|||
|
},
|
|||
|
startOfWeek(date) {
|
|||
|
const d = this.safeDate(date)
|
|||
|
const day = d.getDay()
|
|||
|
d.setDate(d.getDate() - day)
|
|||
|
return d
|
|||
|
},
|
|||
|
pad(num) {
|
|||
|
return num < 10 ? '0' + num : '' + num
|
|||
|
},
|
|||
|
|
|||
|
// =================== 渲染日历 ===================
|
|||
|
generateRenderDays() {
|
|||
|
const days = []
|
|||
|
if (this.mode === 'month') {
|
|||
|
const year = this.anchorDate.getFullYear()
|
|||
|
const month = this.anchorDate.getMonth() + 1
|
|||
|
const firstDayWeek = new Date(year, month - 1, 1).getDay()
|
|||
|
const monthDays = new Date(year, month, 0).getDate()
|
|||
|
|
|||
|
// 上月补位
|
|||
|
const lastMonth = month === 1 ? 12 : month - 1
|
|||
|
const lastMonthYear = month === 1 ? year - 1 : year
|
|||
|
const lastMonthDays = new Date(lastMonthYear, lastMonth, 0).getDate()
|
|||
|
for (let i = 0; i < firstDayWeek; i++) {
|
|||
|
days.push({
|
|||
|
day: lastMonthDays - firstDayWeek + 1 + i,
|
|||
|
date: this.formatDate(new Date(lastMonthYear, lastMonth - 1, lastMonthDays - firstDayWeek + 1 + i)),
|
|||
|
otherMonth: true,
|
|||
|
isToday: false
|
|||
|
})
|
|||
|
}
|
|||
|
|
|||
|
// 本月
|
|||
|
const todayStr = this.formatDate(new Date())
|
|||
|
for (let i = 1; i <= monthDays; i++) {
|
|||
|
const dateStr = `${year}-${this.pad(month)}-${this.pad(i)}`
|
|||
|
days.push({
|
|||
|
day: i,
|
|||
|
date: dateStr,
|
|||
|
otherMonth: false,
|
|||
|
isToday: dateStr === todayStr
|
|||
|
})
|
|||
|
}
|
|||
|
|
|||
|
// 下月补位
|
|||
|
while (days.length % 7 !== 0) {
|
|||
|
const nextDay = days.length - (firstDayWeek + monthDays) + 1
|
|||
|
days.push({
|
|||
|
day: nextDay,
|
|||
|
date: this.formatDate(new Date(month === 12 ? year + 1 : year, month % 12, nextDay)),
|
|||
|
otherMonth: true,
|
|||
|
isToday: false
|
|||
|
})
|
|||
|
}
|
|||
|
} else {
|
|||
|
// 周视图
|
|||
|
const base = this.safeDate(this.selected)
|
|||
|
const weekDay = base.getDay()
|
|||
|
for (let i = 0; i < 7; i++) {
|
|||
|
const d = new Date(base)
|
|||
|
d.setDate(base.getDate() - weekDay + i)
|
|||
|
days.push({
|
|||
|
day: d.getDate(),
|
|||
|
date: this.formatDate(d),
|
|||
|
otherMonth: d.getMonth() !== base.getMonth(),
|
|||
|
isToday: this.formatDate(d) === this.formatDate(new Date())
|
|||
|
})
|
|||
|
}
|
|||
|
}
|
|||
|
this.renderDays = days
|
|||
|
},
|
|||
|
|
|||
|
// =================== 事件 ===================
|
|||
|
toggleMode() {
|
|||
|
if (!this.allowWeekSwitch) return
|
|||
|
this.mode = this.mode === 'month' ? 'week' : 'month'
|
|||
|
this.anchorDate = this.mode === 'month' ? this.startOfMonth(this.safeDate(this.selected)) : this.startOfWeek(this.safeDate(this.selected))
|
|||
|
this.generateRenderDays()
|
|||
|
},
|
|||
|
selectDate(item) {
|
|||
|
if (!item.day) return
|
|||
|
this.selected = item.date
|
|||
|
this.$emit('dateChange', item.date)
|
|||
|
},
|
|||
|
isSelected(item) {
|
|||
|
return item.date === this.selected && !item.otherMonth
|
|||
|
},
|
|||
|
|
|||
|
// =================== 滑动 ===================
|
|||
|
onTouchStart(e) {
|
|||
|
const t = e.changedTouches[0]
|
|||
|
this.touchStartX = t.clientX
|
|||
|
this.touchStartY = t.clientY
|
|||
|
},
|
|||
|
onTouchMove(e) {
|
|||
|
const t = e.changedTouches[0]
|
|||
|
this.touchMoveX = t.clientX
|
|||
|
this.touchMoveY = t.clientY
|
|||
|
},
|
|||
|
onTouchEnd(e) {
|
|||
|
const t = e.changedTouches[0]
|
|||
|
const endX = t.clientX
|
|||
|
const endY = t.clientY
|
|||
|
const deltaX = endX - this.touchStartX
|
|||
|
const deltaY = endY - this.touchStartY
|
|||
|
const threshold = 40
|
|||
|
|
|||
|
// 垂直滑动切换周/月
|
|||
|
if (Math.abs(deltaY) > Math.abs(deltaX) && Math.abs(deltaY) > threshold) {
|
|||
|
if (!this.allowWeekSwitch) return
|
|||
|
|
|||
|
if (deltaY < 0 && this.mode !== 'week') {
|
|||
|
this.mode = 'week'
|
|||
|
this.anchorDate = this.startOfWeek(this.safeDate(this.selected))
|
|||
|
this.generateRenderDays()
|
|||
|
} else if (deltaY > 0 && this.mode !== 'month') {
|
|||
|
this.mode = 'month'
|
|||
|
this.anchorDate = this.startOfMonth(this.safeDate(this.selected))
|
|||
|
this.generateRenderDays()
|
|||
|
}
|
|||
|
return
|
|||
|
}
|
|||
|
|
|||
|
// 水平滑动翻页
|
|||
|
if (Math.abs(deltaX) > threshold && Math.abs(deltaX) >= Math.abs(deltaY)) {
|
|||
|
if (deltaX < 0) this.slide(1)
|
|||
|
else this.slide(-1)
|
|||
|
}
|
|||
|
},
|
|||
|
slide(direction) {
|
|||
|
if (this.mode === 'month') {
|
|||
|
const y = this.anchorDate.getFullYear()
|
|||
|
let m = this.anchorDate.getMonth() + 1 + direction
|
|||
|
let year = y
|
|||
|
if (m > 12) { m = 1; year++ }
|
|||
|
if (m < 1) { m = 12; year-- }
|
|||
|
this.anchorDate = new Date(year, m - 1, 1)
|
|||
|
} else {
|
|||
|
const d = this.safeDate(this.selected)
|
|||
|
d.setDate(d.getDate() + direction * 7)
|
|||
|
this.selected = this.formatDate(d)
|
|||
|
this.anchorDate = this.startOfWeek(d)
|
|||
|
}
|
|||
|
this.generateRenderDays()
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
</script>
|
|||
|
|
|||
|
<style scoped>
|
|||
|
.calendar-container {
|
|||
|
width: 100%;
|
|||
|
background: #fff;
|
|||
|
border-radius: 12rpx;
|
|||
|
box-shadow: 0 2rpx 16rpx #eee;
|
|||
|
padding: 12rpx;
|
|||
|
}
|
|||
|
.calendar-header {
|
|||
|
display: flex;
|
|||
|
align-items: center;
|
|||
|
justify-content: space-between;
|
|||
|
margin-bottom: 8rpx;
|
|||
|
}
|
|||
|
.calendar-content {
|
|||
|
overflow: hidden;
|
|||
|
}
|
|||
|
.calendar-week {
|
|||
|
display: grid;
|
|||
|
grid-template-columns: repeat(7, 1fr);
|
|||
|
color: #999;
|
|||
|
font-size: 24rpx;
|
|||
|
padding: 8rpx 0;
|
|||
|
text-align: center;
|
|||
|
}
|
|||
|
.calendar-days {
|
|||
|
display: grid;
|
|||
|
grid-template-columns: repeat(7, 1fr);
|
|||
|
grid-auto-rows: 80rpx; /* 每个格子固定高度 */
|
|||
|
}
|
|||
|
.calendar-day {
|
|||
|
display: flex;
|
|||
|
align-items: center;
|
|||
|
justify-content: center;
|
|||
|
width: 60rpx; /* 固定宽度 */
|
|||
|
height: 60rpx; /* 固定高度,和宽度一致 */
|
|||
|
margin: auto; /* 居中对齐,避免被挤压 */
|
|||
|
font-size: 30rpx;
|
|||
|
border-radius: 50%;
|
|||
|
transition: background 0.2s;
|
|||
|
}
|
|||
|
.today {
|
|||
|
color: #007aff;
|
|||
|
font-weight: bold;
|
|||
|
border: 1rpx solid #007aff;
|
|||
|
}
|
|||
|
.selected {
|
|||
|
background: #007aff;
|
|||
|
color: #fff;
|
|||
|
}
|
|||
|
.other-month {
|
|||
|
color: #ccc;
|
|||
|
}
|
|||
|
</style>
|