| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683 |
- <template>
- <FlexCol innerClass="nana-calendar" align="stretch">
- <FlexRow v-if="!scrollMode" center :gap="20">
- <IconButton icon="arrow-double-left" @click="prev('year')" />
- <IconButton icon="arrow-left-bold" @click="prev('month')" />
- <Text>{{ DateUtils.formatDate(currentShowMonth, props.topMonthFormat) }}</Text>
- <IconButton icon="arrow-right-bold" @click="next('month')" />
- <IconButton icon="arrow-double-right" @click="next('year')" />
- </FlexRow>
- <Height :size="20" />
- <FlexRow position="relative">
- <FlexCol v-if="scrollMode" width="100%">
- <FlexRow innerClass="nana-calendar-days" wrap justify="space-between">
- <CalendarItem
- v-for="weekText in props.weekTexts"
- :key="weekText"
- :disabled="true"
- :header="true"
- :date="weekText"
- :text="weekText"
- :textProps="headerTextProps"
- />
- </FlexRow>
- <FixedVirtualList
- direction="vertical"
- :itemSize="scrollModePageHeight"
- :containerStyle="{
- width: '100%',
- height: theme.resolveSize(scrollModePageHeight * 2),
- }"
- :data="dayGridsScroll"
- :bufferSize="1"
- class="nana-calendar-scroll"
- >
- <template #item="{ item }">
- <FlexCol position="relative">
- <FlexRow justify="center">
- <Text :text="item.title" />
- </FlexRow>
- <FlexRow innerClass="nana-calendar-days" wrap justify="flex-start">
- <template
- v-for="(day, i) in item.days"
- :key="i"
- >
- <view v-if="day.type === 'invisible'" style="width:14.28%"></view>
- <CalendarItem
- v-else
- :disabled="day.type === 'outOfRange'"
- :text="day.text"
- :textColor="day.color"
- :textProps="{
- ...dayTextProps,
- ...day.textProps,
- }"
- :date="day.date"
- :bottomText="day.bottomText?.text"
- :bottomTextProps="day.bottomText?.textProps"
- :topText="day.topText?.text"
- :topTextProps="day.topText?.textProps"
- @click="handleClick(day)"
- />
- </template>
- </FlexRow>
- <Text
- innerClass="nana-calendar-bg-text"
- color="lightGrey"
- :fontSize="150"
- v-bind="backgroundTextProps"
- >
- {{ item.month }}
- </Text>
- </FlexCol>
- </template>
- </FixedVirtualList>
- </FlexCol>
- <template v-else>
- <FlexRow innerClass="nana-calendar-days" wrap justify="space-between">
- <CalendarItem
- v-for="weekText in props.weekTexts"
- :key="weekText"
- :disabled="true"
- :header="true"
- :date="weekText"
- :text="weekText"
- :textProps="headerTextProps"
- />
- <CalendarItem
- v-for="(day, i) in dayGrids"
- :key="i"
- :disabled="day.type === 'outOfRange'"
- :text="day.text"
- :textColor="day.color"
- :textProps="{
- ...dayTextProps,
- ...day.textProps,
- }"
- :date="day.date"
- :bottomText="day.bottomText?.text"
- :bottomTextProps="day.bottomText?.textProps"
- :topText="day.topText?.text"
- :topTextProps="day.topText?.textProps"
- @click="handleClick(day)"
- />
- </FlexRow>
- <Text
- innerClass="nana-calendar-bg-text"
- color="lightGrey"
- :fontSize="150"
- v-bind="backgroundTextProps"
- >
- {{ currentMonth + 1 }}
- </Text>
- </template>
- </FlexRow>
- </FlexCol>
- </template>
- <script setup lang="ts">
- import { computed, provide, ref, toRef, watch, type ComputedRef, type Ref } from 'vue';
- import { useTheme } from '../theme/ThemeDefine';
- import { DateUtils } from '@imengyu/imengyu-utils';
- import IconButton from '../basic/IconButton.vue';
- import Text from '../basic/Text.vue';
- import FlexCol from '../layout/FlexCol.vue';
- import FlexRow from '../layout/FlexRow.vue';
- import Height from '../layout/space/Height.vue';
- import CalendarItem from './CalendarItem.vue';
- import type { TextProps } from '../basic/Text.vue';
- import calendar, { getFestival } from './CalendarUtils';
- import type { FlexProps } from '../layout/FlexView.vue';
- import FixedVirtualList from '../list/FixedVirtualList.vue';
- export type CalendarPickType = 'day'|'range'|'days';
- export interface CalendarProps {
- /**
- * 选择的日期
- */
- modelValue?: string[] | string;
- /**
- * 进入时当前日历显示的月份
- * @default 当前月份
- */
- currentMonth?: Date;
- /**
- * 顶部月份的格式
- * @default 'yyyy-MM'
- */
- topMonthFormat?: string;
- /**
- * 可以将月份平铺滚动展示,建议只显示最近的几个月,显示过多月份易卡顿。
- * @default false
- */
- scrollMode?: boolean;
- /**
- * 滚动模式下,每页的高度
- * @default 360
- */
- scrollModePageHeight?: number;
- /**
- * 滚动模式下,滚动容器高度,即显示几页(scrollModePageHeight * scrollModePages)
- * @default 2
- */
- scrollModePages?: number;
- /**
- * 日期格式
- * @default 'yyyy-MM-dd'
- */
- dateFormat?: string;
- /**
- * 选择模式:
- * * day 选择一天
- * * range 选择范围
- * * days 选择多天
- * @default 'day'
- */
- pickType?: CalendarPickType;
- /**
- * 设置选择后在日期按钮上的状态文本,
- * 可以是一个字符串,也可以是一个数组,选中的日期按索引顺序读取文字。
- */
- pickStateText?: string|string[];
- /**
- * 限制开始日期
- * @default 5年前
- */
- startDate?: string;
- /**
- * 限制结束日期
- * @default 10年后
- */
- endDate?: string;
- /**
- * 条目的自定义属性
- */
- itemProps?: FlexProps;
- /**
- * 是否展示固定节日。
- * 数据来源于 @swjs/chinese-holidays
- * @default true
- */
- showFestival?: boolean;
- /**
- * 在日期上、下方显示自定义文字,可以用于显示节假日、事件、优惠信息等
- */
- extraDayStrings?: Record<string, {
- text: string;
- textProps?: TextProps,
- topText?: string;
- topTextProps?: TextProps,
- }>;
- /**
- * 设置不可选的日期
- */
- disabledDates?: string[];
- /**
- * 最大可选天数,仅在模式为 days, range 时生效。超出最大可选天数会触发 pickOverMaxDays 事件。为0则不限制
- * @default 0
- */
- maxPickRangeDays?: number;
- /**
- * 自定义周文字
- * @default ['日', '一', '二', '三', '四', '五', '六']
- */
- weekTexts?: string[];
- /**
- * 自定义头部日期文字的样式
- */
- headerTextProps?: TextProps,
- /**
- * 自定义日期文字的样式
- */
- dayTextProps?: TextProps,
- /**
- * 自定义单个日期文字的样式
- */
- daysTextProps?: Record<string, TextProps>,
- /**
- * 自定义背景文字的样式
- */
- backgroundTextProps?: TextProps,
- }
- const emit = defineEmits([ 'update:modelValue', 'pickOverMaxDays', 'selectTextChange' ]);
- const props = withDefaults(defineProps<CalendarProps>(), {
- pickType: 'day',
- showFestival: true,
- maxPickRangeDays: 0,
- scrollModePageHeight: 360,
- scrollModePages: 2,
- dateFormat: 'yyyy-MM-dd',
- topMonthFormat: 'yyyy年MM月',
- currentMonth: () => new Date(),
- startDate: (props) => {
- const now = new Date();
- return DateUtils.formatDate(new Date(`${now.getFullYear() - 5}-01-01`), props.dateFormat);
- },
- endDate: (props) => {
- const now = new Date();
- return DateUtils.formatDate(new Date(`${now.getFullYear() + 10}-12-31`), props.dateFormat);
- },
- weekTexts: () => ['日', '一', '二', '三', '四', '五', '六'],
- });
- const currentYear = ref(props.currentMonth.getFullYear());
- const currentMonth = ref(props.currentMonth.getMonth());
- const startDate = computed(() => props.startDate ? new Date(props.startDate) : new Date('2000-01-01'));
- const endDate = computed(() => props.endDate ? new Date(props.endDate) : new Date('2035-12-31'));
- watch(() => props.currentMonth, (newVal) => {
- currentYear.value = newVal.getFullYear();
- currentMonth.value = newVal.getMonth();
- });
- interface DayGridInfo {
- text: string,
- color: string,
- date: string,
- bottomText?: {
- text: string,
- textProps?: TextProps,
- },
- topText?: {
- text: string,
- textProps?: TextProps,
- },
- textProps?: TextProps,
- type: 'prev'|'current'|'next'|'outOfRange'|'invisible',
- }
- const itemColors = computed(() => theme.resolveThemeColors({
- 'CalendarItemColorNormal': 'transparent',
- 'CalendarItemColorSelected': 'primary',
- 'CalendarItemColorSelectedInRange': 'mask.primary',
- 'CalendarItemColorDisabled': 'transparent',
- 'CalendarTextColorDisabled': 'text.second',
- 'CalendarTextColorNormal': 'text.content',
- 'CalendarTextColorNormalWeekend': 'danger',
- 'CalendarTextColorSelected': 'white',
- 'CalendarTextColorSelectedInRange': 'primary',
- }));
- const itemSizes = computed(() => theme.resolveThemeSizes({
- 'CalendarTopTextSize': 22,
- 'CalendarTextSize': 30,
- 'CalendarBottomTextSize': 22,
- }));
- const currentShowMonth = computed(() => new Date(`${currentYear.value}-${currentMonth.value + 1}-01`));
- const dayGrids = computed(() => {
- const weekOfStartDay = currentShowMonth.value.getDay();
- const daysInMonth = DateUtils.getMonthDays(currentYear.value, currentMonth.value)!;
- let prevYear = currentYear.value;
- let prevMonth = currentMonth.value - 1;
- if (prevMonth < 0) {
- prevMonth = 11;
- prevYear--;
- }
- let nextYear = currentYear.value;
- let nextMonth = currentMonth.value + 1;
- if (nextMonth >= 12) {
- nextMonth = 0;
- nextYear++;
- }
- const daysPrevMonth = DateUtils.getMonthDays(prevYear, prevMonth)!;
- const result = [] as DayGridInfo[];
- //上一个月的日期
- for(let i = daysPrevMonth, j = weekOfStartDay - 1; i > 0 && j >= 0; i--, j--) {
- const date = new Date(`${prevYear}-${prevMonth + 1}-${i}`);
- result.unshift({
- text: i.toString(),
- color: itemColors.value.CalendarTextColorDisabled,
- date: DateUtils.formatDate(date, props.dateFormat),
- type: 'prev',
- });
- }
- const isRangePickerAndPickedStart = props.pickType === 'range' && getPickValueArray().length === 1;
- const rangePickerStartString = getPickValueArray()[0];
- const rangePickerStart = rangePickerStartString ? DateUtils.parseDate(rangePickerStartString) : undefined;
- //当前月的日期
- for(let i = 1; i <= daysInMonth; i++) {
- const isWeekend = (i + weekOfStartDay) % 7 === 0 || (i + weekOfStartDay - 1) % 7 === 0;
- const date = new Date(`${currentYear.value}-${currentMonth.value + 1}-${i}`);
- const lunarInfo = calendar.solar2lunar(date.getFullYear(), date.getMonth() + 1, date.getDate());
- const festival = getFestival(date);
- const dateString = DateUtils.formatDate(date, props.dateFormat);
- const item : DayGridInfo = {
- text: i.toString(),
- color: isWeekend ? itemColors.value.CalendarTextColorNormalWeekend : itemColors.value.CalendarTextColorNormal,
- date: dateString,
- type: 'current',
- };
- if (date.getTime() < startDate.value.getTime() || date.getTime() > endDate.value.getTime())
- item.type = 'outOfRange';
- else if (isRangePickerAndPickedStart && rangePickerStart) {
- if (
- (props.maxPickRangeDays > 0 && ((date.getTime() - rangePickerStart.getTime()) / 864000000) > props.maxPickRangeDays)//选择范围情况下,超出最长可选天数
- || (date < rangePickerStart)
- )
- item.type = 'outOfRange';
- }
- if (props.daysTextProps?.[dateString]) {
- item.textProps = props.daysTextProps[dateString];
- }
- if (props.extraDayStrings?.[dateString]) {
- item.bottomText = {
- text: props.extraDayStrings[dateString].text,
- textProps: props.extraDayStrings[dateString].textProps,
- };
- if (props.extraDayStrings[dateString].topText) {
- item.topText = {
- text: props.extraDayStrings[dateString].topText,
- textProps: props.extraDayStrings[dateString].topTextProps,
- };
- }
- } else if (props.showFestival) {
- item.bottomText = {
- text: festival || (lunarInfo.IDayCn === '初一' ? lunarInfo.IMonthCn : lunarInfo.IDayCn) || '',
- textProps: {},
- };
- } else if (isWeekend) {
- item.color = itemColors.value.CalendarTextColorNormalWeekend;
- }
- result.push(item);
- }
- //下一个月的日期
- for(let j = 1; j <= 7 - ((daysInMonth + weekOfStartDay) % 7); j++) {
- const date = new Date(`${nextYear}-${nextMonth + 1}-${j}`);
- result.push({
- text: j.toString(),
- color: itemColors.value.CalendarTextColorDisabled,
- date: DateUtils.formatDate(date, props.dateFormat),
- type: 'next',
- });
- }
- return result;
- });
- const dayGridsScroll = computed(() => {
- const startDate = DateUtils.parseDate(props.startDate);
- const endDate = DateUtils.parseDate(props.endDate);
- const endYear = endDate.getFullYear();
- const endMonth = endDate.getMonth();
- let startYear = startDate.getFullYear();
- let startMonth = startDate.getMonth();
- const resultGroup : {
- title: string,
- days: DayGridInfo[],
- month: number,
- }[] = [];
- while (true) {
- const weekOfStartDay = new Date(`${startYear}-${startMonth + 1}-1`).getDay();
- const daysInMonth = DateUtils.getMonthDays(startYear, startMonth)!;
- let prevYear = startYear;
- let prevMonth = startMonth - 1;
- if (prevMonth < 0) {
- prevMonth = 11;
- prevYear--;
- }
- const daysPrevMonth = DateUtils.getMonthDays(prevYear, prevMonth)!;
- const result = {
- title: DateUtils.formatDate(new Date(`${startYear}-${startMonth + 1}-1`), props.topMonthFormat),
- days: [] as DayGridInfo[],
- month: startMonth + 1,
- };
-
- const isRangePickerAndPickedStart = props.pickType === 'range' && getPickValueArray().length === 1;
- const rangePickerStartString = getPickValueArray()[0];
- const rangePickerStart = rangePickerStartString ? DateUtils.parseDate(rangePickerStartString) : undefined;
- //上一个月的日期
- for(let i = daysPrevMonth, j = weekOfStartDay - 1; i > 0 && j >= 0; i--, j--) {
- const date = new Date(`${prevYear}-${prevMonth + 1}-${i}`);
- result.days.unshift({
- text: i.toString(),
- color: itemColors.value.CalendarTextColorDisabled,
- date: DateUtils.formatDate(date, props.dateFormat),
- type: 'invisible',
- });
- }
- //当前月的日期
- for(let i = 1; i <= daysInMonth; i++) {
- const isWeekend = (i + weekOfStartDay) % 7 === 0 || (i + weekOfStartDay - 1) % 7 === 0;
- const date = new Date(`${startYear}-${startMonth + 1}-${i}`);
- const lunarInfo = calendar.solar2lunar(date.getFullYear(), date.getMonth() + 1, date.getDate());
- const festival = getFestival(date);
- const dateString = DateUtils.formatDate(date, props.dateFormat);
- const item : DayGridInfo = {
- text: i.toString(),
- color: isWeekend ? itemColors.value.CalendarTextColorNormalWeekend : itemColors.value.CalendarTextColorNormal,
- date: dateString,
- type: 'current',
- };
- if (date.getTime() < startDate.getTime() || date.getTime() > endDate.getTime())
- item.type = 'outOfRange';
- else if (isRangePickerAndPickedStart && rangePickerStart) {
- if (
- (props.maxPickRangeDays > 0 && ((date.getTime() - rangePickerStart.getTime()) / 86400000) > props.maxPickRangeDays)//选择范围情况下,超出最长可选天数
- || (date < rangePickerStart)
- )
- item.type = 'outOfRange';
- }
- if (props.daysTextProps?.[dateString]) {
- item.textProps = props.daysTextProps[dateString];
- }
- if (props.extraDayStrings?.[dateString]) {
- item.bottomText = {
- text: props.extraDayStrings[dateString].text,
- textProps: props.extraDayStrings[dateString].textProps,
- };
- if (props.extraDayStrings[dateString].topText) {
- item.topText = {
- text: props.extraDayStrings[dateString].topText,
- textProps: props.extraDayStrings[dateString].topTextProps,
- };
- }
- } else if (props.showFestival) {
- item.bottomText = {
- text: festival || (lunarInfo.IDayCn === '初一' ? lunarInfo.IMonthCn : lunarInfo.IDayCn) || '',
- textProps: {},
- };
- } else if (isWeekend) {
- item.color = itemColors.value.CalendarTextColorNormalWeekend;
- }
- result.days.push(item);
- }
- resultGroup.push(result);
- startMonth++;
- if (startMonth > 11) {
- startMonth = 0;
- startYear++;
- }
- if (startYear > endYear)
- break;
- }
- return resultGroup;
- });
- function prev(type: 'month'|'year') {
- let now = new Date(currentShowMonth.value);
- if (type === 'month') {
- if (now.getMonth() === 0) {
- now.setFullYear(now.getFullYear() - 1);
- now.setMonth(11);
- } else {
- now.setMonth(now.getMonth() - 1);
- }
- } else {
- now.setFullYear(now.getFullYear() - 1);
- }
- if (now.getTime() < startDate.value.getTime())
- now = startDate.value;
- currentYear.value = now.getFullYear();
- currentMonth.value = now.getMonth();
- }
- function next(type: 'month'|'year') {
- let now = new Date(currentShowMonth.value);
- if (type === 'month') {
- if (now.getMonth() === 11) {
- now.setFullYear(now.getFullYear() + 1);
- now.setMonth(0);
- } else {
- now.setMonth(now.getMonth() + 1);
- }
- } else {
- now.setFullYear(now.getFullYear() + 1);
- }
- if (now.getTime() > endDate.value.getTime())
- now = endDate.value;
- currentYear.value = now.getFullYear();
- currentMonth.value = now.getMonth();
- }
- function getPickValueArray() {
- if (typeof props.modelValue === 'string')
- return [props.modelValue];
- return props.modelValue as string[] || [];
- }
- function latePickADay(dayString: string) {
- setTimeout(() => {
- const day = dayGrids.value.find(d => d.date === dayString);
- if (day)
- handleClick(day);
- }, 200);
- }
- function updateValue(value: string|string[]) {
- emit('selectTextChange', typeof value === 'string' ? value : (
- value.length == 2 ?
- `${value[0]} - ${value[1]}` :
- value.join('、')
- ));
- emit('update:modelValue', value);
- }
- function handleClick(day: DayGridInfo) {
- if (day.type === 'next') {
- next('month');
- latePickADay(day.date);
- return;
- }
- else if (day.type === 'prev') {
- prev('month');
- latePickADay(day.date);
- return;
- } else if (day.type === 'outOfRange') {
- return;
- }
- switch (props.pickType) {
- case 'day':
- updateValue(day.date);
- break;
- case 'days': {
- const index = getPickValueArray().indexOf(day.date);
- if (index !== -1)
- updateValue(getPickValueArray().filter((_, i) => i !== index));
- else {
- if (props.maxPickRangeDays > 0 && getPickValueArray().length >= props.maxPickRangeDays) {
- emit('pickOverMaxDays');
- return;
- }
- updateValue(getPickValueArray().concat([ day.date ]));
- }
- break;
- }
- case 'range':
- const len = getPickValueArray().length;
- if (len <= 0 || len >= 2) {
- updateValue([ day.date ]);
- return;
- } else if(len === 1) {
- const pickValue = getPickValueArray()[0];
- const rangePickerStart = DateUtils.parseDate(pickValue);
- if (pickValue === day.date) {
- updateValue([]);
- return;
- }
- if (DateUtils.parseDate(day.date).getTime() <= rangePickerStart.getTime()) {
- emit('pickOverMaxDays');
- return;
- }
- updateValue([ getPickValueArray()[0], day.date ]);
- }
- break;
- default:
- throw new Error('pickType not supported');
- }
- }
- const theme = useTheme();
- export interface CalendarContext {
- itemColors: ComputedRef<Record<string, string>>,
- itemSizes: ComputedRef<Record<string, number>>,
- itemProps: Ref<FlexProps|undefined>,
- disabledDates: Ref<string[]|undefined>,
- pickType: Ref<string>,
- pickValue: Ref<string|string[]|undefined>,
- pickStateText: Ref<string|string[]|undefined>,
- }
- provide<CalendarContext>('calendarContext', {
- itemColors,
- itemSizes,
- itemProps: toRef(props, 'itemProps'),
- disabledDates: toRef(props, 'disabledDates'),
- pickType: toRef(props, 'pickType'),
- pickValue: toRef(props, 'modelValue'),
- pickStateText: toRef(props, 'pickStateText'),
- })
- defineOptions({
- options: {
- styleIsolation: 'shared',
- }
- })
- </script>
- <style lang="scss">
- .nana-calendar-bg-text {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 0;
- }
- .nana-calendar-days {
- position: relative;
- z-index: 1;
- }
- .nana-calendar-scroll {
- max-height: 70vh;
- }
- </style>
|