TaskCalendar.vue
7.19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
<!--
* @Date: 2025-11-19 21:20:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-19 21:22:29
* @FilePath: /mlaj/src/components/ui/TaskCalendar.vue
* @Description: 自定义轻量日历组件(单月视图,7列栅格,点击选择日期;样式参考示例图)
-->
<template>
<div class="TaskCalendar bg-white rounded-lg shadow px-4 py-4 relative">
<!-- 顶部:左右切月 + 月份标题 -->
<div class="header flex items-center justify-between mb-3">
<van-icon name="arrow-left" class="text-gray-500" @click="go_prev_month" />
<div class="monthTitle text-xl font-bold text-gray-900 cursor-pointer" @click="open_month_picker">{{ month_title }}</div>
<van-icon name="arrow" class="text-gray-500 rotate-180" @click="go_next_month" />
</div>
<!-- 年月选择弹窗(使用 Vant DatePicker) -->
<van-popup v-model:show="show_date_picker" position="bottom">
<van-picker-group
title="选择年月"
:tabs="['选择年月']"
@confirm="on_confirm_year_month"
@cancel="on_cancel_year_month"
>
<van-date-picker
v-model="year_month_value"
:min-date="min_date"
:max-date="max_date"
:columns-type="columns_type"
/>
</van-picker-group>
</van-popup>
<!-- 星期标题 -->
<div class="weekRow grid grid-cols-7 gap-3 mb-2 text-center text-gray-500 text-sm">
<div v-for="w in weeks" :key="w">{{ w }}</div>
</div>
<!-- 日期网格:圆形按钮,选中高亮;无日期不显示圆但保留格子位置 -->
<div class="daysGrid grid grid-cols-7 gap-3">
<div v-for="day in grid_days" :key="day.key"
class="dayItem flex items-center justify-center rounded-full" :class="[
(!day.date || day.type !== 'current') ? 'invisible' : 'bg-green-100 text-gray-700',
is_selected(day) ? 'bg-green-500 text-white' : ''
]" @click="on_click_day(day)">
<span v-if="day.date">{{ day.date.getDate() }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
/**
* 组件对外暴露的 v-model 值:选中日期(YYYY-MM-DD)
*/
const props = defineProps({
modelValue: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue', 'select'])
//
// 状态:当前面板年月与选中日期
//
const selected_date = ref(props.modelValue || format_date(new Date()))
const panel_year = ref(Number(selected_date.value.slice(0, 4)))
const panel_month = ref(Number(selected_date.value.slice(5, 7)))
watch(() => props.modelValue, (val) => {
if (val) selected_date.value = val
})
/**
* 月份标题文案
* @returns {string}
*/
const month_title = computed(() => `${panel_year.value}年${panel_month.value}月`)
/**
* 星期标题
*/
const weeks = ref(['日', '一', '二', '三', '四', '五', '六'])
/**
* 生成日历网格(含上月/下月占位)
* @returns {Array<{key:string,type:string,date:Date|null}>}
*/
const grid_days = computed(() => {
const first = new Date(panel_year.value, panel_month.value - 1, 1)
const first_weekday = first.getDay() // 0..6
const days_in_month = new Date(panel_year.value, panel_month.value, 0).getDate()
const days = []
// 上月占位
for (let i = 0; i < first_weekday; i++) {
days.push({ key: `p-${i}`, type: 'prev', date: null })
}
// 当月日期
for (let d = 1; d <= days_in_month; d++) {
const dt = new Date(panel_year.value, panel_month.value - 1, d)
days.push({ key: `c-${d}`, type: 'current', date: dt })
}
// 末尾占位:补至整周
const tail = (7 - (days.length % 7)) % 7
for (let j = 0; j < tail; j++) {
days.push({ key: `n-${j}`, type: 'next', date: null })
}
return days
})
/**
* 判断是否选中该日期
* @param {{type:string,date:Date|null}} day
* @returns {boolean}
*/
function is_selected(day) {
if (!day.date) return false
return format_date(day.date) === selected_date.value
}
/**
* 点击某日期:更新选中,并向外派发事件
* @param {{type:string,date:Date|null}} day
* @returns {void}
*/
function on_click_day(day) {
if (!day.date) return
const val = format_date(day.date)
selected_date.value = val
emit('update:modelValue', val)
emit('select', val)
}
/**
* 切换至上/下月
*/
function go_prev_month() {
const d = new Date(panel_year.value, panel_month.value - 2, 1)
panel_year.value = d.getFullYear()
panel_month.value = d.getMonth() + 1
}
function go_next_month() {
const d = new Date(panel_year.value, panel_month.value, 1)
panel_year.value = d.getFullYear()
panel_month.value = d.getMonth() + 1
}
/**
* 日期格式化为 YYYY-MM-DD
* @param {Date} d - 日期对象
* @returns {string}
*/
function format_date(d) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/**
* 年月选择弹窗相关状态
*/
const show_date_picker = ref(false)
// DatePicker 的 v-model 值(仅年/月),与 Vant 用法保持一致
const year_month_value = ref([String(panel_year.value), String(panel_month.value).padStart(2, '0')])
/**
* DatePicker 列类型(仅年份与月份)
* @type {string[]}
*/
const columns_type = ['year', 'month']
// 选择范围(可按需调整)
const min_date = new Date(2020, 0, 1)
const max_date = new Date(2035, 11, 31)
/**
* 打开年月选择弹窗
* @returns {void}
*/
function open_month_picker() {
// 同步当前面板年月到选择器
year_month_value.value = [String(panel_year.value), String(panel_month.value).padStart(2, '0')]
show_date_picker.value = true
}
/**
* 取消选择年月
* @returns {void}
*/
function on_cancel_year_month() {
show_date_picker.value = false
}
/**
* 确认选择年月:更新面板年月并关闭弹窗
* @returns {void}
*/
function on_confirm_year_month() {
const [y, m] = year_month_value.value
panel_year.value = Number(y)
panel_month.value = Number(m)
show_date_picker.value = false
}
</script>
<style lang="less" scoped>
.TaskCalendar {
// 防止内部内容溢出容器尺寸
width: 100%;
max-width: 100%;
overflow: hidden;
.header {
.monthTitle {
line-height: 1.3;
}
}
.weekRow {
// 兜底栅格:确保7列布局,避免类未编译导致竖排
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.daysGrid {
// 兜底栅格:确保7列布局,避免类未编译导致竖排
display: grid;
grid-template-columns: repeat(7, 1fr);
.dayItem {
// 自适应圆点尺寸:不超过容器列宽与2.5rem,保持正圆
width: 100%;
max-width: 2.5rem;
aspect-ratio: 1 / 1;
border-radius: 9999px;
justify-self: center;
transition: all 0.2s ease-in-out;
}
}
}
</style>