TaskCalendar.vue 7.19 KB
<!--
 * @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>