466 lines
11 KiB
Vue
466 lines
11 KiB
Vue
<template>
|
||
<view class="inline-calendar" :style="calendarStyle">
|
||
<view class="calendar-header" v-if="showMonthNav">
|
||
<view class="nav-btn" @click="prevMonth">
|
||
<text class="nav-icon">‹</text>
|
||
</view>
|
||
<text class="month-title">{{ currentYear }}年{{ currentMonth }}月</text>
|
||
<view class="nav-btn" @click="nextMonth">
|
||
<text class="nav-icon">›</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="weekday-header">
|
||
<view class="weekday-cell" v-for="(day, i) in weekdayLabels" :key="'wd-' + i">
|
||
<text class="weekday-text" :class="{ 'is-weekend': i === 5 || i === 6 }">{{ day }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="days-grid">
|
||
<view
|
||
class="day-row"
|
||
v-for="(week, wi) in weeks"
|
||
:key="wi"
|
||
>
|
||
<view
|
||
class="day-item"
|
||
:class="getDayClass(date)"
|
||
:style="getDayStyle(date)"
|
||
v-for="(date, di) in week"
|
||
:key="di"
|
||
@click="onDateClick(date)"
|
||
>
|
||
<text class="day-number">{{ date.getDate() }}</text>
|
||
<view
|
||
class="mark-dot"
|
||
v-if="isMarked(date) && markType === 'dot'"
|
||
></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import themeMixin from '@/mixins/themeMixin.js'
|
||
import { THEME_CHANGE_EVENT } from '@/utils/theme'
|
||
|
||
// 周一到周日的完整中文名(展示在日历数字上方)
|
||
const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日']
|
||
|
||
export default {
|
||
name: 'InlineCalendar',
|
||
mixins: [themeMixin],
|
||
props: {
|
||
value: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
markDates: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
markType: {
|
||
type: String,
|
||
default: 'dot'
|
||
},
|
||
rangeSelect: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
startDate: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
endDate: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
showMonthNav: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
minDate: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
maxDate: {
|
||
type: String,
|
||
default: ''
|
||
}
|
||
},
|
||
data() {
|
||
return {
|
||
currentDate: new Date(),
|
||
innerStartDate: '',
|
||
innerEndDate: ''
|
||
}
|
||
},
|
||
computed: {
|
||
calendarStyle() {
|
||
return {
|
||
backgroundColor: this.themeConfig.cardBgColor,
|
||
borderRadius: '20rpx'
|
||
}
|
||
},
|
||
weekdayLabels() {
|
||
return WEEKDAY_LABELS
|
||
},
|
||
currentYear() {
|
||
return this.currentDate.getFullYear()
|
||
},
|
||
currentMonth() {
|
||
return this.currentDate.getMonth() + 1
|
||
},
|
||
todayStr() {
|
||
return this.formatDate(new Date())
|
||
},
|
||
weeks() {
|
||
const year = this.currentDate.getFullYear()
|
||
const month = this.currentDate.getMonth()
|
||
const firstDay = new Date(year, month, 1)
|
||
const dayOfWeek = firstDay.getDay()
|
||
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1
|
||
const gridStart = new Date(year, month, 1 - daysSinceMonday)
|
||
|
||
const result = []
|
||
for (let w = 0; w < 6; w++) {
|
||
const week = []
|
||
for (let d = 0; d < 7; d++) {
|
||
const date = new Date(gridStart)
|
||
date.setDate(gridStart.getDate() + w * 7 + d)
|
||
week.push(date)
|
||
}
|
||
result.push(week)
|
||
}
|
||
return result
|
||
},
|
||
effectiveStart() {
|
||
return this.rangeSelect ? (this.startDate || this.innerStartDate) : ''
|
||
},
|
||
effectiveEnd() {
|
||
return this.rangeSelect ? (this.endDate || this.innerEndDate) : ''
|
||
}
|
||
},
|
||
watch: {
|
||
value: {
|
||
immediate: true,
|
||
handler(val) {
|
||
if (val) {
|
||
const d = this.parseDate(val)
|
||
if (d) {
|
||
this.currentDate = new Date(d.getFullYear(), d.getMonth(), 1)
|
||
}
|
||
}
|
||
}
|
||
},
|
||
startDate(val) {
|
||
if (val) this.innerStartDate = val
|
||
},
|
||
endDate(val) {
|
||
if (val) this.innerEndDate = val
|
||
}
|
||
},
|
||
created() {
|
||
this.syncThemeConfig()
|
||
this.watchThemeChange()
|
||
if (this.value) {
|
||
const d = this.parseDate(this.value)
|
||
if (d) {
|
||
this.currentDate = new Date(d.getFullYear(), d.getMonth(), 1)
|
||
}
|
||
}
|
||
if (this.startDate) this.innerStartDate = this.startDate
|
||
if (this.endDate) this.innerEndDate = this.endDate
|
||
},
|
||
beforeUnmount() {
|
||
if (this.themeChangeHandler) {
|
||
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||
}
|
||
},
|
||
methods: {
|
||
formatDate(date) {
|
||
const y = date.getFullYear()
|
||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||
const d = String(date.getDate()).padStart(2, '0')
|
||
return `${y}-${m}-${d}`
|
||
},
|
||
parseDate(str) {
|
||
if (!str) return null
|
||
const parts = str.split('-')
|
||
if (parts.length !== 3) return null
|
||
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]))
|
||
},
|
||
isSameDay(d1, d2) {
|
||
return d1.getFullYear() === d2.getFullYear() &&
|
||
d1.getMonth() === d2.getMonth() &&
|
||
d1.getDate() === d2.getDate()
|
||
},
|
||
isToday(date) {
|
||
return this.isSameDay(date, new Date())
|
||
},
|
||
isCurrentMonth(date) {
|
||
return date.getFullYear() === this.currentYear &&
|
||
date.getMonth() === this.currentMonth - 1
|
||
},
|
||
isMarked(date) {
|
||
const str = this.formatDate(date)
|
||
return this.markDates.indexOf(str) !== -1
|
||
},
|
||
isDisabled(date) {
|
||
const str = this.formatDate(date)
|
||
if (this.minDate && str < this.minDate) return true
|
||
if (this.maxDate && str > this.maxDate) return true
|
||
return false
|
||
},
|
||
getDayClass(date) {
|
||
const str = this.formatDate(date)
|
||
const classes = {}
|
||
if (!this.isCurrentMonth(date)) classes['day-item--other-month'] = true
|
||
if (this.isToday(date)) classes['day-item--today'] = true
|
||
if (this.isDisabled(date)) classes['day-item--disabled'] = true
|
||
|
||
if (this.rangeSelect) {
|
||
if (this.effectiveStart && this.effectiveEnd) {
|
||
if (str === this.effectiveStart || str === this.effectiveEnd) {
|
||
classes['day-item--selected'] = true
|
||
} else if (str > this.effectiveStart && str < this.effectiveEnd) {
|
||
classes['day-item--in-range'] = true
|
||
}
|
||
} else if (this.effectiveStart && str === this.effectiveStart) {
|
||
classes['day-item--selected'] = true
|
||
}
|
||
} else {
|
||
if (this.value && str === this.value) {
|
||
classes['day-item--selected'] = true
|
||
}
|
||
}
|
||
|
||
if (this.isMarked(date) && this.markType === 'bg') {
|
||
classes['day-item--mark-bg'] = true
|
||
}
|
||
|
||
return classes
|
||
},
|
||
getDayStyle(date) {
|
||
const styles = {}
|
||
if (this.isToday(date)) {
|
||
styles.borderColor = this.themeConfig.primaryColor
|
||
}
|
||
if (this.isMarked(date) && this.markType === 'bg') {
|
||
styles.backgroundColor = this.themeConfig.primaryColor + '26'
|
||
}
|
||
if (!this.isCurrentMonth(date)) {
|
||
styles.color = this.themeConfig.textDisabled
|
||
}
|
||
return styles
|
||
},
|
||
onDateClick(date) {
|
||
if (this.isDisabled(date)) return
|
||
const str = this.formatDate(date)
|
||
|
||
if (this.rangeSelect) {
|
||
if (!this.innerStartDate || (this.innerStartDate && this.innerEndDate)) {
|
||
this.innerStartDate = str
|
||
this.innerEndDate = ''
|
||
} else {
|
||
if (str <= this.innerStartDate) {
|
||
this.innerEndDate = this.innerStartDate
|
||
this.innerStartDate = str
|
||
} else {
|
||
this.innerEndDate = str
|
||
}
|
||
this.$emit('rangeChange', {
|
||
start: this.innerStartDate,
|
||
end: this.innerEndDate
|
||
})
|
||
}
|
||
return
|
||
}
|
||
|
||
this.$emit('change', str)
|
||
this.$emit('input', str)
|
||
},
|
||
prevMonth() {
|
||
let y = this.currentYear
|
||
let m = this.currentMonth - 2
|
||
if (m < 0) {
|
||
m = 11
|
||
y--
|
||
}
|
||
this.currentDate = new Date(y, m, 1)
|
||
},
|
||
nextMonth() {
|
||
let y = this.currentYear
|
||
let m = this.currentMonth
|
||
if (m > 11) {
|
||
m = 0
|
||
y++
|
||
}
|
||
this.currentDate = new Date(y, m, 1)
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.inline-calendar {
|
||
padding: 24rpx;
|
||
user-select: none;
|
||
|
||
.calendar-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.nav-btn {
|
||
width: 60rpx;
|
||
height: 60rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 14rpx;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.nav-btn:active {
|
||
background-color: rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.nav-icon {
|
||
font-size: 40rpx;
|
||
color: v-bind('themeConfig.textSecondary');
|
||
font-weight: 300;
|
||
}
|
||
|
||
.month-title {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: v-bind('themeConfig.textPrimary');
|
||
}
|
||
|
||
.weekday-header {
|
||
display: flex;
|
||
gap: 8rpx;
|
||
margin: 0 auto 16rpx;
|
||
width: 552rpx; /* 与 .day-row 7*72 + 6*8 保持一致 */
|
||
}
|
||
|
||
.weekday-cell {
|
||
width: 72rpx;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 36rpx;
|
||
border-radius: 8rpx;
|
||
background-color: v-bind('themeConfig.bgColor');
|
||
}
|
||
|
||
.weekday-text {
|
||
font-size: 24rpx;
|
||
font-weight: 600;
|
||
color: v-bind('themeConfig.textSecondary');
|
||
letter-spacing: 1rpx;
|
||
}
|
||
|
||
.weekday-text.is-weekend {
|
||
color: v-bind('themeConfig.primaryColor');
|
||
}
|
||
|
||
.days-grid {
|
||
display: table;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.day-row {
|
||
display: flex;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.day-item {
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
border-radius: 10rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
background-color: transparent;
|
||
transition: all 0.2s ease;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.day-number {
|
||
font-size: 28rpx;
|
||
color: v-bind('themeConfig.textPrimary');
|
||
line-height: 1;
|
||
}
|
||
|
||
.day-item--other-month {
|
||
opacity: 0.25;
|
||
}
|
||
|
||
.day-item--other-month .day-number {
|
||
color: v-bind('themeConfig.textDisabled');
|
||
}
|
||
|
||
.day-item--today {
|
||
border: 2rpx solid transparent;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.day-item--today .day-number {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.day-item--selected {
|
||
background: v-bind('themeConfig.gradientSoft');
|
||
}
|
||
|
||
.day-item--selected .day-number {
|
||
color: #fff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.day-item--in-range {
|
||
background-color: v-bind('themeConfig.primaryColor + "1A"');
|
||
border-radius: 0;
|
||
}
|
||
|
||
.day-item--in-range .day-number {
|
||
color: v-bind('themeConfig.primaryColor');
|
||
}
|
||
|
||
.day-item--disabled {
|
||
opacity: 0.3;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.day-item--mark-bg {
|
||
background-color: v-bind('themeConfig.primaryColor + "26"');
|
||
}
|
||
|
||
.mark-dot {
|
||
position: absolute;
|
||
bottom: 8rpx;
|
||
width: 8rpx;
|
||
height: 8rpx;
|
||
border-radius: 50%;
|
||
background-color: v-bind('themeConfig.primaryColor');
|
||
}
|
||
|
||
.day-item--selected .mark-dot {
|
||
background-color: #fff;
|
||
}
|
||
|
||
.day-item--in-range .mark-dot {
|
||
background-color: v-bind('themeConfig.primaryColor');
|
||
}
|
||
}
|
||
</style> |