Files
jiapuapp/components/tree/MemberDatePickerSheet.vue
T
2026-09-13 17:45:52 +08:00

424 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view v-if="visible" class="member-date-picker" role="dialog" :aria-label="title">
<view class="member-date-picker__mask" @click="$emit('cancel')" />
<view class="member-date-picker__panel">
<view class="member-date-picker__heading">
<text class="member-date-picker__title">{{ title }}</text>
<text class="member-date-picker__hint">点击年份可直接切换左右按钮切换月份</text>
</view>
<view class="member-date-picker__toolbar">
<button
class="member-date-picker__month-button"
aria-label="上个月"
:disabled="!canSelectPreviousMonth"
@click="changeMonth(-1)"
>
<image
class="member-date-picker__month-icon member-date-picker__month-icon--previous"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</button>
<view class="member-date-picker__current-month">
<picker
:range="yearLabels"
:value="selectedYearIndex"
@change="selectYear"
>
<button class="member-date-picker__year-button">
<text>选择年份</text>
<text>{{ selectedYear }}</text>
</button>
</picker>
<picker
:range="monthLabels"
:value="selectedMonthIndex"
@change="selectMonth"
>
<button class="member-date-picker__month-button-direct" aria-label="选择月份">
<text>选择月份</text>
<text>{{ selectedMonth }}</text>
</button>
</picker>
</view>
<button
class="member-date-picker__month-button"
aria-label="下个月"
:disabled="!canSelectNextMonth"
@click="changeMonth(1)"
>
<image
class="member-date-picker__month-icon"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</button>
</view>
<view class="member-date-picker__weekdays">
<text v-for="weekday in weekdays" :key="weekday">{{ weekday }}</text>
</view>
<view class="member-date-picker__calendar">
<view
v-for="calendarDay in calendarDays"
:key="calendarDay.key"
class="member-date-picker__day-slot"
>
<button
v-if="calendarDay.day"
class="member-date-picker__day"
:class="{
'member-date-picker__day--selected': calendarDay.day === selectedDay,
}"
:disabled="calendarDay.disabled"
:aria-label="`${selectedYear}年${selectedMonth}月${calendarDay.day}日`"
@click="selectedDay = calendarDay.day"
>{{ calendarDay.day }}</button>
</view>
</view>
<view class="member-date-picker__footer">
<button class="member-date-picker__cancel" @click="$emit('cancel')">取消</button>
<button class="member-date-picker__confirm" @click="confirmSelection">确定</button>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref, watch } from "vue";
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: "选择日期" },
value: { type: String, default: "" },
});
const emit = defineEmits(["cancel", "confirm"]);
const MIN_YEAR = 1900;
const today = new Date();
const CURRENT_YEAR = today.getFullYear();
const CURRENT_MONTH = today.getMonth() + 1;
const CURRENT_DAY = today.getDate();
const years = Array.from(
{ length: CURRENT_YEAR - MIN_YEAR + 1 },
(_, index) => MIN_YEAR + index,
);
const yearLabels = years.map((year) => `${year}年`);
const weekdays = Object.freeze(["日", "一", "二", "三", "四", "五", "六"]);
const selectedYear = ref(new Date().getFullYear());
const selectedMonth = ref(new Date().getMonth() + 1);
const selectedDay = ref(new Date().getDate());
const selectedYearIndex = computed(() => selectedYear.value - MIN_YEAR);
const availableMonths = computed(() =>
Array.from(
{ length: selectedYear.value === CURRENT_YEAR ? CURRENT_MONTH : 12 },
(_, index) => index + 1,
),
);
const monthLabels = computed(() =>
availableMonths.value.map((month) => `${month}月`),
);
const selectedMonthIndex = computed(() =>
Math.max(0, availableMonths.value.indexOf(selectedMonth.value)),
);
const daysInSelectedMonth = computed(() =>
new Date(selectedYear.value, selectedMonth.value, 0).getDate(),
);
const selectedMonthKey = computed(
() => selectedYear.value * 12 + selectedMonth.value - 1,
);
const firstMonthKey = MIN_YEAR * 12;
const currentMonthKey = CURRENT_YEAR * 12 + CURRENT_MONTH - 1;
const canSelectPreviousMonth = computed(() => selectedMonthKey.value > firstMonthKey);
const canSelectNextMonth = computed(() => selectedMonthKey.value < currentMonthKey);
const calendarDays = computed(() => {
const leadingDays = new Date(selectedYear.value, selectedMonth.value - 1, 1).getDay();
const slots = Array.from({ length: leadingDays }, (_, index) => ({
key: `empty-${index}`,
day: 0,
}));
for (let day = 1; day <= daysInSelectedMonth.value; day += 1) {
const disabled =
selectedYear.value === CURRENT_YEAR &&
selectedMonth.value === CURRENT_MONTH &&
day > CURRENT_DAY;
slots.push({ key: `day-${day}`, day, disabled });
}
return slots;
});
const clampSelectedDay = () => {
const latestDay =
selectedYear.value === CURRENT_YEAR && selectedMonth.value === CURRENT_MONTH
? CURRENT_DAY
: daysInSelectedMonth.value;
selectedDay.value = Math.min(selectedDay.value, latestDay);
};
const parseInitialDate = () => {
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(props.value);
const fallback = new Date();
const isFutureDate = matched
? new Date(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3])) > today
: false;
if (isFutureDate) {
selectedYear.value = CURRENT_YEAR;
selectedMonth.value = CURRENT_MONTH;
selectedDay.value = CURRENT_DAY;
return;
}
selectedYear.value = matched
? Math.max(MIN_YEAR, Math.min(CURRENT_YEAR, Number(matched[1])))
: fallback.getFullYear();
selectedMonth.value = matched
? Math.max(1, Math.min(12, Number(matched[2])))
: fallback.getMonth() + 1;
selectedDay.value = matched ? Math.max(1, Number(matched[3])) : fallback.getDate();
if (selectedMonthKey.value > currentMonthKey) {
selectedMonth.value = CURRENT_MONTH;
}
clampSelectedDay();
};
watch(
() => props.visible,
(visible) => {
if (visible) parseInitialDate();
},
);
const selectYear = (event) => {
selectedYear.value = years[Number(event.detail.value)] || selectedYear.value;
if (selectedMonthKey.value > currentMonthKey) {
selectedMonth.value = CURRENT_MONTH;
}
clampSelectedDay();
};
const selectMonth = (event) => {
const nextMonth = availableMonths.value[Number(event.detail.value)];
if (!nextMonth) return;
selectedMonth.value = nextMonth;
clampSelectedDay();
};
const changeMonth = (offset) => {
const nextDate = new Date(selectedYear.value, selectedMonth.value - 1 + offset, 1);
const nextYear = nextDate.getFullYear();
const nextMonth = nextDate.getMonth() + 1;
const nextMonthKey = nextYear * 12 + nextMonth - 1;
if (nextMonthKey < firstMonthKey || nextMonthKey > currentMonthKey) return;
selectedYear.value = nextYear;
selectedMonth.value = nextMonth;
clampSelectedDay();
};
const padDatePart = (value) => String(value).padStart(2, "0");
const confirmSelection = () => {
if (selectedMonthKey.value > currentMonthKey) return;
clampSelectedDay();
emit(
"confirm",
`${selectedYear.value}-${padDatePart(selectedMonth.value)}-${padDatePart(
selectedDay.value,
)}`,
);
};
</script>
<style scoped lang="scss">
.member-date-picker {
position: fixed;
z-index: 90;
inset: 0;
display: flex;
align-items: flex-end;
}
.member-date-picker__mask {
position: absolute;
inset: 0;
background: rgba(43, 30, 20, 0.5);
}
.member-date-picker__panel {
position: relative;
box-sizing: border-box;
width: 100%;
padding: 28rpx 30rpx calc(28rpx + env(safe-area-inset-bottom));
border-radius: 28rpx 28rpx 0 0;
background: #fdf8ed;
box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, 0.2);
}
.member-date-picker__heading {
text-align: center;
}
.member-date-picker__title,
.member-date-picker__hint {
display: block;
}
.member-date-picker__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(20px, 36rpx, 25px);
font-weight: 700;
}
.member-date-picker__hint {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.member-date-picker__toolbar {
display: grid;
grid-template-columns: 76rpx minmax(0, 1fr) 76rpx;
align-items: center;
gap: 14rpx;
margin-top: 20rpx;
}
.member-date-picker__month-button {
display: flex;
width: 76rpx;
min-height: 76rpx;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border: 1rpx solid rgba(181, 137, 63, 0.34);
border-radius: 10rpx;
background: rgba(247, 237, 218, 0.7);
}
.member-date-picker__month-button[disabled],
.member-date-picker__day[disabled] {
opacity: 0.32;
}
.member-date-picker__month-button::after,
.member-date-picker__year-button::after,
.member-date-picker__month-button-direct::after,
.member-date-picker__day::after,
.member-date-picker__cancel::after,
.member-date-picker__confirm::after {
display: none;
}
.member-date-picker__month-icon {
width: 28rpx;
height: 28rpx;
}
.member-date-picker__month-icon--previous {
transform: rotate(180deg);
}
.member-date-picker__current-month {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 14rpx;
}
.member-date-picker__year-button {
display: grid;
width: 100%;
min-height: 76rpx;
align-content: center;
margin: 0;
padding: 6rpx 18rpx;
border: 1rpx solid rgba(159, 23, 15, 0.38);
border-radius: 10rpx;
background: rgba(255, 250, 240, 0.92);
color: $brand-red;
line-height: 1.25;
}
.member-date-picker__year-button text:first-child {
font-size: clamp(12px, 19rpx, 15px);
}
.member-date-picker__year-button text:last-child {
font-size: clamp(17px, 29rpx, 21px);
font-weight: 700;
}
.member-date-picker__month-button-direct {
display: grid;
min-width: 116rpx;
min-height: 76rpx;
align-content: center;
margin: 0;
padding: 6rpx 12rpx;
border: 1rpx solid rgba(181, 137, 63, 0.34);
border-radius: 10rpx;
background: rgba(247, 237, 218, 0.7);
color: $ink;
line-height: 1.25;
white-space: nowrap;
}
.member-date-picker__month-button-direct text:first-child {
color: $ink-muted;
font-size: clamp(12px, 19rpx, 15px);
}
.member-date-picker__month-button-direct text:last-child {
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 29rpx, 21px);
font-weight: 700;
}
.member-date-picker__weekdays,
.member-date-picker__calendar {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.member-date-picker__weekdays {
margin-top: 20rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
text-align: center;
}
.member-date-picker__calendar {
margin-top: 8rpx;
padding: 8rpx;
border: 1rpx solid rgba(181, 137, 63, 0.3);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.76);
}
.member-date-picker__day-slot {
display: flex;
min-height: 70rpx;
align-items: center;
justify-content: center;
}
.member-date-picker__day {
display: flex;
width: 58rpx;
min-height: 58rpx;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $ink;
font-size: clamp(14px, 23rpx, 18px);
line-height: 1;
}
.member-date-picker__day--selected {
background: $brand-red;
color: #fff9ed;
font-weight: 700;
}
.member-date-picker__footer {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
margin-top: 20rpx;
}
.member-date-picker__cancel,
.member-date-picker__confirm {
min-height: 82rpx;
margin: 0;
border-radius: 10rpx;
font-size: clamp(16px, 28rpx, 20px);
font-weight: 700;
}
.member-date-picker__cancel {
border: 1rpx solid rgba(159, 23, 15, 0.34);
background: transparent;
color: $brand-red;
}
.member-date-picker__confirm {
border: 0;
background: $brand-red;
color: #fff9ed;
}
</style>