Files
----/前端源码/uni-app/components/MysticDatePicker.vue

577 lines
16 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>
<transition name="mystic-date-picker">
<div v-if="isOpen" class="mystic-date-picker-overlay">
<!-- Backdrop -->
<div class="mystic-date-picker-backdrop" @click="handleClose"></div>
<!-- Modal Content -->
<div class="mystic-date-picker-modal">
<!-- Header -->
<div class="mystic-date-picker-header">
<div class="mystic-date-picker-close" @click="handleClose">
<CloseIcon :size="22" class="mystic-date-picker-icon" />
</div>
<span class="mystic-date-picker-title">{{ title }}</span>
<div class="mystic-date-picker-confirm" @click="handleConfirm">
<CheckIcon :size="22" class="mystic-date-picker-icon" />
</div>
</div>
<!-- Picker View -->
<div class="mystic-date-picker-view">
<div class="mystic-date-picker-indicator"></div>
<!-- Year Column -->
<div class="mystic-date-picker-column" @scroll="handleScroll($event, 0)">
<div class="mystic-date-picker-padding"></div>
<div v-for="(y, idx) in years" :key="y" :ref="el => setColumnRef(el, 0, idx)"
class="mystic-date-picker-item" :class="{ active: pickerValue[0] === idx }" @click="selectItem(0, idx)">
<span>{{ y }}</span>
</div>
<div class="mystic-date-picker-padding"></div>
</div>
<!-- Month Column -->
<div class="mystic-date-picker-column" @scroll="handleScroll($event, 1)">
<div class="mystic-date-picker-padding"></div>
<div v-for="(m, idx) in months" :key="idx" :ref="el => setColumnRef(el, 1, idx)"
class="mystic-date-picker-item" :class="{ active: pickerValue[1] === idx }" @click="selectItem(1, idx)">
<span>{{ m }}</span>
</div>
<div class="mystic-date-picker-padding"></div>
</div>
<!-- Day Column -->
<div class="mystic-date-picker-column" @scroll="handleScroll($event, 2)">
<div class="mystic-date-picker-padding"></div>
<div v-for="(d, idx) in days" :key="d.val" :ref="el => setColumnRef(el, 2, idx)"
class="mystic-date-picker-item" :class="{ active: pickerValue[2] === idx }" @click="selectItem(2, idx)">
<span>{{ d.name }}</span>
</div>
<div class="mystic-date-picker-padding"></div>
</div>
<!-- Time Column -->
<div class="mystic-date-picker-column" @scroll="handleScroll($event, 3)">
<div class="mystic-date-picker-padding"></div>
<div v-for="(s, idx) in shichenOptions" :key="s.id" :ref="el => setColumnRef(el, 3, idx)"
class="mystic-date-picker-item mystic-date-picker-item-time" :class="{ active: pickerValue[3] === idx }"
@click="selectItem(3, idx)">
<span class="mystic-date-picker-time-name">{{ s.name }}</span>
<span class="mystic-date-picker-time-detail">{{ s.time }}</span>
</div>
<div class="mystic-date-picker-padding"></div>
</div>
</div>
<!-- Footer Tip -->
<div class="mystic-date-picker-footer">
<span class="mystic-date-picker-tip">{{ footerTip || '滑动列表选择 · 系统自动换算干支' }}</span>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import CloseIcon from './icons/CloseIcon.vue';
import CheckIcon from './icons/CheckIcon.vue';
interface Props {
isOpen: boolean;
title?: string;
defaultValue?: string;
/** 年份列下界(公历,含)。与 maxYear 同时用于自定义区间(如择吉期望范围可选至未来多年) */
minYear?: number;
/** 年份列上界(公历,含) */
maxYear?: number;
/** 底部提示,便于与其它场景日期选择区分 */
footerTip?: string;
/** 为 true 时公历日期不可晚于「今天」(用于择吉期望开始日等) */
capAtToday?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
title: '请择良辰',
defaultValue: '',
minYear: undefined,
maxYear: undefined,
footerTip: '',
capAtToday: false,
});
const emit = defineEmits<{
close: [];
confirm: [val: string, apiVal: string];
}>();
// 时辰对照表
const SHI_CHEN = [
{ id: 'zi', name: '子时', time: '23:00-00:59', hour: '00:00:00' },
{ id: 'chou', name: '丑时', time: '01:00-02:59', hour: '02:00:00' },
{ id: 'yin', name: '寅时', time: '03:00-04:59', hour: '04:00:00' },
{ id: 'mao', name: '卯时', time: '05:00-06:59', hour: '06:00:00' },
{ id: 'chen', name: '辰时', time: '07:00-08:59', hour: '08:00:00' },
{ id: 'si', name: '巳时', time: '09:00-10:59', hour: '10:00:00' },
{ id: 'wu', name: '午时', time: '11:00-12:59', hour: '12:00:00' },
{ id: 'wei', name: '未时', time: '13:00-14:59', hour: '14:00:00' },
{ id: 'shen', name: '申时', time: '15:00-16:59', hour: '16:00:00' },
{ id: 'you', name: '酉时', time: '17:00-18:59', hour: '18:00:00' },
{ id: 'xu', name: '戌时', time: '19:00-20:59', hour: '20:00:00' },
{ id: 'hai', name: '亥时', time: '21:00-22:59', hour: '22:00:00' },
];
const CH_NUM = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
const MONTHS = ['正月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '冬月', '腊月'];
/**
* 年份列(降序):默认从当年起共 86 年(生辰等场景,不可超过当年);
* 若传入 minYear/maxYear 则按该区间生成(用于择吉期望日期等可选未来多年)。
*/
const years = computed(() => {
const nowY = new Date().getFullYear();
const useCustom = props.minYear !== undefined || props.maxYear !== undefined;
if (!useCustom) {
const maxY = nowY;
const minY = maxY - 85;
return Array.from({ length: 86 }, (_, i) => maxY - i);
}
const maxY = props.maxYear ?? nowY;
const minY = props.minYear ?? maxY - 85;
const hi = Math.max(minY, maxY);
const lo = Math.min(minY, maxY);
return Array.from({ length: hi - lo + 1 }, (_, i) => hi - i);
});
const months = MONTHS;
const shichenOptions = SHI_CHEN;
const pickerValue = ref([0, 0, 0, 0]);
const columnRefs = ref<Array<Array<HTMLElement | null>>>([[], [], [], []]);
const ITEM_HEIGHT = 50;
const setColumnRef = (el: any, columnIdx: number, itemIdx: number) => {
if (el) {
if (!columnRefs.value[columnIdx]) {
columnRefs.value[columnIdx] = [];
}
columnRefs.value[columnIdx][itemIdx] = el;
}
};
const isLeapYear = (year: number): boolean => {
return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
};
const getDaysInMonth = (year: number, month: number): number => {
const daysMap = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
if (month === 1 && isLeapYear(year)) {
return 29;
}
return daysMap[month];
};
const getDayName = (d: number): string => {
if (d <= 10) return `${CH_NUM[d]}`;
if (d === 20) return '二十';
if (d === 30) return '三十';
if (d < 20) return `${CH_NUM[d - 10]}`;
if (d < 30) return `廿${CH_NUM[d - 20]}`;
return `三十${CH_NUM[d - 30]}`;
};
const days = computed(() => {
const [yearIdx, monthIdx] = pickerValue.value;
const year = years.value[yearIdx] || 2000;
const daysCount = getDaysInMonth(year, monthIdx);
return Array.from({ length: daysCount }, (_, i) => ({
val: i + 1,
name: getDayName(i + 1)
}));
});
const scrollToIndex = (columnIdx: number, index: number, smooth = true) => {
const column = document.querySelectorAll('.mystic-date-picker-column')[columnIdx] as HTMLElement;
if (column) {
const scrollTop = index * ITEM_HEIGHT;
column.scrollTo({
top: scrollTop,
behavior: smooth ? 'smooth' : 'auto'
});
}
};
const selectItem = (columnIdx: number, index: number) => {
let newValue = [...pickerValue.value];
newValue[columnIdx] = index;
// 检查日期是否超出当月天数
if (columnIdx === 0 || columnIdx === 1) {
const [yearIdx, monthIdx] = newValue;
const year = years.value[yearIdx] || 2000;
const maxDays = getDaysInMonth(year, monthIdx);
if (newValue[2] >= maxDays) {
newValue[2] = maxDays - 1;
}
}
newValue = clampPickerValueToTodayMax(newValue, years.value);
pickerValue.value = newValue;
nextTick(() => {
pickerValue.value.forEach((val, idx) => {
scrollToIndex(idx, val, false);
});
});
};
let scrollTimer: number | null = null;
const handleScroll = (e: Event, columnIdx: number) => {
if (scrollTimer) {
clearTimeout(scrollTimer);
}
scrollTimer = window.setTimeout(() => {
const target = e.target as HTMLElement;
const scrollTop = target.scrollTop;
const index = Math.round(scrollTop / ITEM_HEIGHT);
selectItem(columnIdx, index);
}, 150);
};
const parseDefaultValue = (val: string, yearList: number[]) => {
if (!val) return null;
const yearMatch = val.match(/(\d+)年/);
const monthMatch = val.match(/年(.+?)(?:初|十|廿|三十)/);
const shichenMatch = val.match(/(子|丑|寅|卯|辰|巳|午|未|申|酉|戌|亥)时/);
if (!yearMatch) return null;
const year = parseInt(yearMatch[1]);
const yearIdx = yearList.findIndex(y => y === year);
let monthIdx = 0;
if (monthMatch) {
monthIdx = MONTHS.findIndex(m => m === monthMatch[1]);
if (monthIdx < 0) monthIdx = 0;
}
let dayIdx = 0;
const dayPart = val.replace(/\d+年/, '').replace(/.*月/, '').replace(/(子|丑|寅|卯|辰|巳|午|未|申|酉|戌|亥)时/, '');
if (dayPart) {
for (let i = 1; i <= 31; i++) {
if (getDayName(i) === dayPart) {
dayIdx = i - 1;
break;
}
}
}
let shichenIdx = 0;
if (shichenMatch) {
shichenIdx = SHI_CHEN.findIndex(s => s.name.startsWith(shichenMatch[1]));
if (shichenIdx < 0) shichenIdx = 0;
}
return {
yearIdx: yearIdx >= 0 ? yearIdx : 0,
monthIdx,
dayIdx,
shichenIdx
};
};
const clampPickerDay = (parts: number[], yearList: number[]): number[] => {
if (!yearList.length) return [0, 0, 0, 0];
const yearIdx = Math.min(Math.max(0, parts[0]), yearList.length - 1);
const monthIdx = Math.min(Math.max(0, parts[1]), 11);
const y = yearList[yearIdx] ?? new Date().getFullYear();
const maxD = getDaysInMonth(y, monthIdx);
const dayIdx = Math.min(Math.max(0, parts[2]), maxD - 1);
const shichenIdx = Math.min(Math.max(0, parts[3]), SHI_CHEN.length - 1);
return [yearIdx, monthIdx, dayIdx, shichenIdx];
};
const getTodayCalendar = () => {
const n = new Date();
return { y: n.getFullYear(), m: n.getMonth(), d: n.getDate() };
};
/** capAtToday将公历日期限制在「今天」及之前 */
const clampPickerValueToTodayMax = (parts: number[], yearList: number[]): number[] => {
let next = clampPickerDay(parts, yearList);
if (!props.capAtToday || !yearList.length) return next;
const t = getTodayCalendar();
let [yi, mi, di, si] = next;
const sy = yearList[yi];
if (sy > t.y) {
const ti = yearList.findIndex((yr) => yr === t.y);
if (ti >= 0) next = clampPickerDay([ti, t.m, t.d - 1, si], yearList);
return next;
}
if (sy < t.y) return next;
if (mi > t.m) {
next = clampPickerDay([yi, t.m, t.d - 1, si], yearList);
return next;
}
if (mi < t.m) return next;
if (di > t.d - 1) {
next = clampPickerDay([yi, mi, t.d - 1, si], yearList);
}
return next;
};
watch(() => props.isOpen, (newVal) => {
if (newVal) {
const ylist = years.value;
const parsed = parseDefaultValue(props.defaultValue, ylist);
if (parsed) {
const yi = Math.min(Math.max(0, parsed.yearIdx), Math.max(0, ylist.length - 1));
pickerValue.value = clampPickerValueToTodayMax(
[yi, parsed.monthIdx, parsed.dayIdx, parsed.shichenIdx],
ylist
);
} else {
const now = new Date();
const currentYear = now.getFullYear();
const yearIdx = ylist.findIndex(y => y === currentYear);
const monthIdx = now.getMonth();
const dayIdx = now.getDate() - 1;
const y = yearIdx >= 0 ? ylist[yearIdx] : ylist[0];
const pickY = yearIdx >= 0 ? yearIdx : 0;
pickerValue.value = clampPickerValueToTodayMax(
[
pickY,
monthIdx,
Math.min(dayIdx, getDaysInMonth(y, monthIdx) - 1),
0,
],
ylist
);
}
nextTick(() => {
pickerValue.value.forEach((val, idx) => {
scrollToIndex(idx, val, false);
});
});
}
});
const handleClose = () => {
emit('close');
};
const handleConfirm = () => {
const ylist = years.value;
pickerValue.value = clampPickerValueToTodayMax(pickerValue.value, ylist);
const [yearIdx, monthIdx, dayIdx, shichenIdx] = pickerValue.value;
const selectedYear = years.value[yearIdx] ?? years.value[0];
const selectedMonth = MONTHS[monthIdx];
const selectedDay = days.value[dayIdx]?.name || getDayName(dayIdx + 1);
const selectedShichen = shichenOptions[shichenIdx].name;
const displayStr = `${selectedYear}${selectedMonth}${selectedDay}${selectedShichen}`;
const month = String(monthIdx + 1).padStart(2, '0');
const day = String(dayIdx + 1).padStart(2, '0');
const hour = shichenOptions[shichenIdx].hour;
const apiStr = `${selectedYear}-${month}-${day} ${hour}`;
emit('confirm', displayStr, apiStr);
handleClose();
};
</script>
<style scoped>
.mystic-date-picker-overlay {
position: fixed;
inset: 0;
z-index: 999;
display: flex;
align-items: flex-end;
justify-content: center;
}
.mystic-date-picker-backdrop {
position: absolute;
inset: 0;
background: rgba(26, 26, 26, 0.6);
backdrop-filter: blur(4px);
}
.mystic-date-picker-modal {
position: relative;
width: 100%;
background: #fcfaf5;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
overflow: hidden;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.25);
border-top: 4px solid #8b2323;
}
.mystic-date-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eaddcf;
background: #f9f7f2;
}
.mystic-date-picker-close,
.mystic-date-picker-confirm {
padding: 8px;
color: #5a5a5a;
cursor: pointer;
transition: opacity 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.mystic-date-picker-close:active,
.mystic-date-picker-confirm:active {
opacity: 0.6;
}
.mystic-date-picker-confirm {
color: #8b2323;
}
.mystic-date-picker-icon {
display: block;
}
.mystic-date-picker-title {
font-size: 18px;
font-weight: bold;
color: #2c2c2c;
letter-spacing: 0.3em;
font-family: SimSun, "Songti SC", serif;
}
.mystic-date-picker-view {
height: 300px;
width: 100%;
display: flex;
position: relative;
overflow: hidden;
}
.mystic-date-picker-indicator {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 50px;
transform: translateY(-50%);
border-top: 1px solid #dcd3c9;
border-bottom: 1px solid #dcd3c9;
background: rgba(139, 35, 35, 0.02);
pointer-events: none;
z-index: 1;
}
.mystic-date-picker-column {
flex: 1;
height: 100%;
overflow-y: auto;
scroll-snap-type: y mandatory;
-webkit-overflow-scrolling: touch;
}
.mystic-date-picker-column::-webkit-scrollbar {
display: none;
}
.mystic-date-picker-padding {
height: 125px;
flex-shrink: 0;
}
.mystic-date-picker-item {
height: 50px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 14px;
color: #8a8a8a;
font-family: SimSun, "Songti SC", serif;
cursor: pointer;
transition: all 0.3s;
scroll-snap-align: center;
flex-shrink: 0;
}
.mystic-date-picker-item.active {
color: #2c2c2c;
font-weight: bold;
font-size: 16px;
}
.mystic-date-picker-item-time {
gap: 2px;
}
.mystic-date-picker-time-name {
font-weight: bold;
font-size: 14px;
}
.mystic-date-picker-item.active .mystic-date-picker-time-name {
font-size: 16px;
}
.mystic-date-picker-time-detail {
font-size: 10px;
opacity: 0.6;
color: #8a8a8a;
}
.mystic-date-picker-footer {
background: #f9f7f2;
padding: 12px;
text-align: center;
border-top: 1px solid #eaddcf;
}
.mystic-date-picker-tip {
font-size: 11px;
color: #8a8a8a;
letter-spacing: 0.1em;
}
/* Transition */
.mystic-date-picker-enter-active,
.mystic-date-picker-leave-active {
transition: opacity 0.3s;
}
.mystic-date-picker-enter-active .mystic-date-picker-modal,
.mystic-date-picker-leave-active .mystic-date-picker-modal {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.mystic-date-picker-enter-from,
.mystic-date-picker-leave-to {
opacity: 0;
}
.mystic-date-picker-enter-from .mystic-date-picker-modal,
.mystic-date-picker-leave-to .mystic-date-picker-modal {
transform: translateY(100%);
}
</style>