Files
WAI_Project_UNIX/uni_modules/cool-ui/components/cl-calendar/cl-calendar.uvue
2025-10-27 10:17:55 +08:00

927 lines
21 KiB
Plaintext
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 class="cl-calendar" :class="[pt.className]">
<!-- 年月选择器弹窗 -->
<calendar-picker
:year="currentYear"
:month="currentMonth"
:ref="refs.set('picker')"
@change="onYearMonthChange"
></calendar-picker>
<!-- 头部导航栏 -->
<view class="cl-calendar__header" v-if="showHeader">
<!-- 上一月按钮 -->
<view
class="cl-calendar__header-prev"
:class="{ 'is-dark': isDark }"
@tap.stop="gotoPrevMonth"
>
<cl-icon name="arrow-left-s-line"></cl-icon>
</view>
<!-- 当前年月显示区域 -->
<view class="cl-calendar__header-date" @tap="refs.open('picker')">
<slot name="current-date">
<cl-text :pt="{ className: 'text-lg' }">{{
$t(`{year}年{month}月`, { year: currentYear, month: currentMonth })
}}</cl-text>
</slot>
</view>
<!-- 下一月按钮 -->
<view
class="cl-calendar__header-next"
:class="{ 'is-dark': isDark }"
@tap.stop="gotoNextMonth"
>
<cl-icon name="arrow-right-s-line"></cl-icon>
</view>
</view>
<!-- 星期标题行 -->
<view class="cl-calendar__weeks" :style="{ gap: `${cellGap}px` }" v-if="showWeeks">
<view class="cl-calendar__weeks-item" v-for="weekName in weekLabels" :key="weekName">
<cl-text>{{ weekName }}</cl-text>
</view>
</view>
<!-- 日期网格容器 -->
<view
class="cl-calendar__view"
ref="viewRef"
:style="{ height: `${viewHeight}px`, gap: `${cellGap}px` }"
@tap="onTap"
>
<!-- #ifndef APP -->
<view
class="cl-calendar__view-row"
:style="{ gap: `${cellGap}px` }"
v-for="(weekRow, rowIndex) in dateMatrix"
:key="rowIndex"
>
<view
class="cl-calendar__view-cell"
v-for="(dateCell, cellIndex) in weekRow"
:key="cellIndex"
:class="{
'is-selected': dateCell.isSelected,
'is-range': dateCell.isRange,
'is-hide': dateCell.isHide,
'is-disabled': dateCell.isDisabled,
'is-today': dateCell.isToday,
'is-other-month': !dateCell.isCurrentMonth
}"
:style="{
height: cellHeight + 'px',
backgroundColor: getCellBgColor(dateCell)
}"
@click.stop="selectDateCell(dateCell)"
>
<!-- 顶部文本 -->
<cl-text
:size="20"
:color="getCellTextColor(dateCell)"
:pt="{
className: 'absolute top-[2px]'
}"
>{{ dateCell.topText }}</cl-text
>
<!-- 主日期数字 -->
<cl-text
:color="getCellTextColor(dateCell)"
:size="`${fontSize}px`"
:pt="{
className: 'font-bold'
}"
>{{ dateCell.date }}</cl-text
>
<!-- 底部文本 -->
<cl-text
:size="20"
:color="getCellTextColor(dateCell)"
:pt="{
className: 'absolute bottom-[2px]'
}"
>{{ dateCell.bottomText }}</cl-text
>
</view>
</view>
<!-- #endif -->
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch, type PropType } from "vue";
import { ctx, dayUts, first, isDark, isHarmony, parsePt, useRefs } from "@/cool";
import CalendarPicker from "./picker.uvue";
import { $t, t } from "@/locale";
import type { ClCalendarDateConfig, ClCalendarMode } from "../../types";
import { useSize } from "../../hooks";
defineOptions({
name: "cl-calendar"
});
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 当前选中的日期值(单选模式)
modelValue: {
type: String as PropType<string | null>,
default: null
},
// 选中的日期数组(多选/范围模式)
date: {
type: Array as PropType<string[]>,
default: () => []
},
// 日期选择模式:单选/多选/范围选择
mode: {
type: String as PropType<ClCalendarMode>,
default: "single"
},
// 日期配置
dateConfig: {
type: Array as PropType<ClCalendarDateConfig[]>,
default: () => []
},
// 开始日期,可选日期的开始
start: {
type: String
},
// 结束日期,可选日期的结束
end: {
type: String
},
// 设置年份
year: {
type: Number
},
// 设置月份
month: {
type: Number
},
// 是否显示其他月份的日期
showOtherMonth: {
type: Boolean,
default: true
},
// 是否显示头部导航栏
showHeader: {
type: Boolean,
default: true
},
// 是否显示星期
showWeeks: {
type: Boolean,
default: true
},
// 单元格高度
cellHeight: {
type: Number,
default: 66
},
// 单元格间距
cellGap: {
type: Number,
default: 0
},
// 主色
color: {
type: String,
default: ""
},
// 当前月份日期颜色
textColor: {
type: String,
default: ""
},
// 其他月份日期颜色
textOtherMonthColor: {
type: String,
default: ""
},
// 禁用日期颜色
textDisabledColor: {
type: String,
default: ""
},
// 今天日期颜色
textTodayColor: {
type: String,
default: "#ff6b6b"
},
// 选中日期颜色
textSelectedColor: {
type: String,
default: "#ffffff"
},
// 选中日期背景颜色
bgSelectedColor: {
type: String,
default: ""
},
// 范围选择背景颜色
bgRangeColor: {
type: String,
default: ""
}
});
// 事件发射器定义
const emit = defineEmits(["update:modelValue", "update:date", "change"]);
// 日期单元格数据结构
type DateCell = {
date: string; // 显示的日期数字
isCurrentMonth: boolean; // 是否属于当前显示月份
isToday: boolean; // 是否为今天
isSelected: boolean; // 是否被选中
isRange: boolean; // 是否在选择范围内
fullDate: string; // 完整日期格式 YYYY-MM-DD
isDisabled: boolean; // 是否被禁用
isHide: boolean; // 是否隐藏显示
topText: string; // 顶部文案
bottomText: string; // 底部文案
color: string; // 颜色
};
// 透传样式属性类型
type PassThrough = {
className?: string;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 字体大小
const { getPxValue } = useSize();
// 主色
const color = computed(() => {
if (props.color != "") {
return props.color;
}
return ctx.color["primary-500"] as string;
});
// 单元格高度
const cellHeight = computed(() => props.cellHeight);
// 单元格间距
const cellGap = computed(() => props.cellGap);
// 字体大小
const fontSize = computed(() => {
// #ifdef APP
return getPxValue("14px");
// #endif
// #ifndef APP
return 14;
// #endif
});
// 当前月份日期颜色
const textColor = computed(() => {
if (props.textColor != "") {
return props.textColor;
}
return isDark.value ? "white" : (ctx.color["surface-700"] as string);
});
// 其他月份日期颜色
const textOtherMonthColor = computed(() => {
if (props.textOtherMonthColor != "") {
return props.textOtherMonthColor;
}
return isDark.value
? (ctx.color["surface-500"] as string)
: (ctx.color["surface-300"] as string);
});
// 禁用日期颜色
const textDisabledColor = computed(() => {
if (props.textDisabledColor != "") {
return props.textDisabledColor;
}
return isDark.value
? (ctx.color["surface-500"] as string)
: (ctx.color["surface-300"] as string);
});
// 今天日期颜色
const textTodayColor = computed(() => props.textTodayColor);
// 选中日期颜色
const textSelectedColor = computed(() => props.textSelectedColor);
// 选中日期背景颜色
const bgSelectedColor = computed(() => {
if (props.bgSelectedColor != "") {
return props.bgSelectedColor;
}
return color.value;
});
// 范围选择背景颜色
const bgRangeColor = computed(() => {
if (props.bgRangeColor != "") {
return props.bgRangeColor;
}
return isHarmony() ? (ctx.color["primary-50"] as string) : color.value + "11";
});
// 组件引用管理器
const refs = useRefs();
// 日历视图DOM元素引用
const viewRef = ref<UniElement | null>(null);
// 当前显示的年份
const currentYear = ref(0);
// 当前显示的月份
const currentMonth = ref(0);
// 视图高度
const viewHeight = computed(() => {
return cellHeight.value * 6;
});
// 单元格宽度
const cellWidth = ref(0);
// 星期标签数组
const weekLabels = computed(() => {
return [t("周日"), t("周一"), t("周二"), t("周三"), t("周四"), t("周五"), t("周六")];
});
// 日历日期矩阵数据6行7列
const dateMatrix = ref<DateCell[][]>([]);
// 当前选中的日期列表
const selectedDates = ref<string[]>([]);
/**
* 获取日历视图元素的位置信息
*/
async function getViewRect(): Promise<DOMRect | null> {
return viewRef.value!.getBoundingClientRectAsync();
}
/**
* 判断指定日期是否被选中
* @param dateStr 日期字符串 YYYY-MM-DD
*/
function isDateSelected(dateStr: string): boolean {
if (props.mode == "single") {
// 单选模式:检查是否为唯一选中日期
return selectedDates.value[0] == dateStr;
} else {
// 多选/范围模式:检查是否在选中列表中
return selectedDates.value.includes(dateStr);
}
}
/**
* 判断指定日期是否被禁用
* @param dateStr 日期字符串 YYYY-MM-DD
*/
function isDateDisabled(dateStr: string): boolean {
// 大于开始日期
if (props.start != null) {
if (dayUts(dateStr).isBefore(dayUts(props.start))) {
return true;
}
}
// 小于结束日期
if (props.end != null) {
if (dayUts(dateStr).isAfter(dayUts(props.end))) {
return true;
}
}
return props.dateConfig.some((config) => config.date == dateStr && config.disabled == true);
}
/**
* 判断指定日期是否在选择范围内(不包括端点)
* @param dateStr 日期字符串 YYYY-MM-DD
*/
function isDateInRange(dateStr: string): boolean {
// 仅范围选择模式且已选择两个端点时才有范围
if (props.mode != "range" || selectedDates.value.length != 2) {
return false;
}
const [startDate, endDate] = selectedDates.value;
const currentDate = dayUts(dateStr);
return currentDate.isAfter(startDate) && currentDate.isBefore(endDate);
}
/**
* 获取单元格字体颜色
* @param dateCell 日期单元格数据
* @returns 字体颜色
*/
function getCellTextColor(dateCell: DateCell): string {
// 选中的日期文字颜色
if (dateCell.isSelected) {
return textSelectedColor.value;
}
if (dateCell.color != "") {
return dateCell.color;
}
// 范围选择日期颜色
if (dateCell.isRange) {
return color.value;
}
// 禁用的日期颜色
if (dateCell.isDisabled) {
return textDisabledColor.value;
}
// 今天日期颜色
if (dateCell.isToday) {
return textTodayColor.value;
}
// 当前月份日期颜色
if (dateCell.isCurrentMonth) {
return textColor.value;
}
// 其他月份日期颜色
return textOtherMonthColor.value;
}
/**
* 获取单元格背景颜色
* @param dateCell 日期单元格数据
* @returns 背景颜色
*/
function getCellBgColor(dateCell: DateCell): string {
if (dateCell.isSelected) {
return bgSelectedColor.value;
}
if (dateCell.isRange) {
return bgRangeColor.value;
}
return "transparent";
}
/**
* 计算并生成日历矩阵数据
* 生成6行7列共42个日期包含上月末尾和下月开头的日期
*/
function calculateDateMatrix() {
const weekRows: DateCell[][] = [];
const todayStr = dayUts().format("YYYY-MM-DD"); // 今天的日期字符串
// 获取当前月第一天
const monthFirstDay = dayUts(`${currentYear.value}-${currentMonth.value}-01`);
const firstDayWeekIndex = monthFirstDay.getDay(); // 第一天是星期几 (0=周日, 6=周六)
// 计算日历显示的起始日期(可能是上个月的日期)
const calendarStartDate = monthFirstDay.subtract(firstDayWeekIndex, "day");
// 生成6周的日期数据6行 × 7列 = 42天
let iterateDate = calendarStartDate;
for (let weekIndex = 0; weekIndex < 6; weekIndex++) {
const weekDates: DateCell[] = [];
for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
const fullDateStr = iterateDate.format("YYYY-MM-DD");
const nativeDate = iterateDate.toDate();
const dayNumber = nativeDate.getDate();
// 判断是否属于当前显示月份
const belongsToCurrentMonth =
nativeDate.getMonth() + 1 == currentMonth.value &&
nativeDate.getFullYear() == currentYear.value;
// 日期配置
const dateConfig = props.dateConfig.find((config) => config.date == fullDateStr);
// 构建日期单元格数据
const dateCell = {
date: `${dayNumber}`,
isCurrentMonth: belongsToCurrentMonth,
isToday: fullDateStr == todayStr,
isSelected: isDateSelected(fullDateStr),
isRange: isDateInRange(fullDateStr),
fullDate: fullDateStr,
isDisabled: isDateDisabled(fullDateStr),
isHide: false,
topText: dateConfig?.topText ?? "",
bottomText: dateConfig?.bottomText ?? "",
color: dateConfig?.color ?? ""
} as DateCell;
// 根据配置决定是否隐藏相邻月份的日期
if (!props.showOtherMonth && !belongsToCurrentMonth) {
dateCell.isHide = true;
}
weekDates.push(dateCell);
iterateDate = iterateDate.add(1, "day"); // 移动到下一天
}
weekRows.push(weekDates);
}
dateMatrix.value = weekRows;
}
/**
* 使用Canvas绘制日历仅APP端
* Web端使用DOM渲染APP端使用Canvas提升性能
*/
async function renderCalendar() {
// #ifdef APP
await nextTick(); // 等待DOM更新完成
const ctx = viewRef.value!.getDrawableContext();
if (ctx == null) return;
ctx!.reset(); // 清空画布
/**
* 绘制单个日期单元格
* @param dateCell 日期单元格数据
* @param colIndex 列索引 (0-6)
* @param rowIndex 行索引 (0-5)
*/
function drawSingleCell(dateCell: DateCell, colIndex: number, rowIndex: number) {
// 计算单元格位置
const cellX = colIndex * cellWidth.value;
const cellY = rowIndex * cellHeight.value;
const centerX = cellX + cellWidth.value / 2;
const centerY = cellY + cellHeight.value / 2;
// 绘制背景(选中状态或范围状态)
if (dateCell.isSelected || dateCell.isRange) {
const padding = cellGap.value; // 使用间距作为内边距
const bgX = cellX + padding;
const bgY = cellY + padding;
const bgWidth = cellWidth.value - padding * 2;
const bgHeight = cellHeight.value - padding * 2;
// 设置背景颜色
if (dateCell.isSelected) {
ctx!.fillStyle = bgSelectedColor.value;
}
if (dateCell.isRange) {
ctx!.fillStyle = bgRangeColor.value;
}
ctx!.fillRect(bgX, bgY, bgWidth, bgHeight); // 绘制背景矩形
}
// 获取单元格文字颜色
const cellTextColor = getCellTextColor(dateCell);
ctx!.textAlign = "center";
// 绘制顶部文本
if (dateCell.topText != "") {
ctx!.font = `${Math.floor(fontSize.value * 0.75)}px sans-serif`;
ctx!.fillStyle = cellTextColor;
const topY = cellY + 16; // 距离顶部
ctx!.fillText(dateCell.topText, centerX, topY);
}
// 绘制主日期数字
ctx!.font = `${fontSize.value}px sans-serif`;
ctx!.fillStyle = cellTextColor;
const textOffsetY = (fontSize.value / 2) * 0.7;
ctx!.fillText(dateCell.date.toString(), centerX, centerY + textOffsetY);
// 绘制底部文本
if (dateCell.bottomText != "") {
ctx!.font = `${Math.floor(fontSize.value * 0.75)}px sans-serif`;
ctx!.fillStyle = cellTextColor;
const bottomY = cellY + cellHeight.value - 8; // 距离底部
ctx!.fillText(dateCell.bottomText, centerX, bottomY);
}
}
// 获取容器尺寸信息
const viewRect = await getViewRect();
if (viewRect == null) {
return;
}
// 计算单元格宽度总宽度除以7列
const cellSize = viewRect.width / 7;
// 更新渲染配置
cellWidth.value = cellSize;
// 遍历日期矩阵进行绘制
for (let rowIndex = 0; rowIndex < dateMatrix.value.length; rowIndex++) {
const weekRow = dateMatrix.value[rowIndex];
for (let colIndex = 0; colIndex < weekRow.length; colIndex++) {
const dateCell = weekRow[colIndex];
if (!dateCell.isHide) {
drawSingleCell(dateCell, colIndex, rowIndex);
}
}
}
ctx!.update(); // 更新画布显示
// #endif
}
/**
* 处理日期单元格选择逻辑
* @param dateCell 被点击的日期单元格
*/
function selectDateCell(dateCell: DateCell) {
// 隐藏或禁用的日期不可选择
if (dateCell.isHide || dateCell.isDisabled) {
return;
}
if (props.mode == "single") {
// 单选模式:直接替换选中日期
selectedDates.value = [dateCell.fullDate];
emit("update:modelValue", dateCell.fullDate);
} else if (props.mode == "multiple") {
// 多选模式:切换选中状态
const existingIndex = selectedDates.value.indexOf(dateCell.fullDate);
if (existingIndex >= 0) {
// 已选中则移除
selectedDates.value.splice(existingIndex, 1);
} else {
// 未选中则添加
selectedDates.value.push(dateCell.fullDate);
}
} else {
// 范围选择模式
if (selectedDates.value.length == 0) {
// 第一次点击:设置起始日期
selectedDates.value = [dateCell.fullDate];
} else if (selectedDates.value.length == 1) {
// 第二次点击:设置结束日期
const startDate = dayUts(selectedDates.value[0]);
const endDate = dayUts(dateCell.fullDate);
if (endDate.isBefore(startDate)) {
// 结束日期早于开始日期时自动交换
selectedDates.value = [dateCell.fullDate, selectedDates.value[0]];
} else {
selectedDates.value = [selectedDates.value[0], dateCell.fullDate];
}
} else {
// 已有范围时重新开始选择
selectedDates.value = [dateCell.fullDate];
}
}
// 发射更新事件
emit("update:date", [...selectedDates.value]);
emit("change", selectedDates.value);
// 重新计算日历数据并重绘
calculateDateMatrix();
renderCalendar();
}
/**
* 处理年月选择器的变化事件
* @param yearMonthArray [年份, 月份] 数组
*/
function onYearMonthChange(yearMonthArray: number[]) {
currentYear.value = yearMonthArray[0];
currentMonth.value = yearMonthArray[1];
// 重新计算日历数据并重绘
calculateDateMatrix();
renderCalendar();
}
/**
* 处理点击事件APP端点击检测
*/
async function onTap(e: UniPointerEvent) {
// 获取容器位置信息
const viewRect = await getViewRect();
if (viewRect == null) {
return;
}
// 计算触摸点相对于容器的坐标
const relativeX = e.clientX - viewRect.left;
const relativeY = e.clientY - viewRect.top;
// 根据坐标计算对应的行列索引
const columnIndex = Math.floor(relativeX / cellWidth.value);
const rowIndex = Math.floor(relativeY / cellHeight.value);
// 边界检查:确保索引在有效范围内
if (
rowIndex < 0 ||
rowIndex >= dateMatrix.value.length ||
columnIndex < 0 ||
columnIndex >= 7
) {
return;
}
const targetDateCell = dateMatrix.value[rowIndex][columnIndex];
selectDateCell(targetDateCell);
}
/**
* 切换到上一个月
*/
function gotoPrevMonth() {
const [newYear, newMonth] = dayUts(`${currentYear.value}-${currentMonth.value}-01`)
.subtract(1, "month")
.toArray();
currentYear.value = newYear;
currentMonth.value = newMonth;
// 重新计算并渲染日历
calculateDateMatrix();
renderCalendar();
}
/**
* 切换到下一个月
*/
function gotoNextMonth() {
const [newYear, newMonth] = dayUts(`${currentYear.value}-${currentMonth.value}-01`)
.add(1, "month")
.toArray();
currentYear.value = newYear;
currentMonth.value = newMonth;
// 重新计算并渲染日历
calculateDateMatrix();
renderCalendar();
}
/**
* 解析选中日期
*/
function parseDate(flag: boolean | null = null) {
// 根据选择模式初始化选中日期
if (props.mode == "single") {
selectedDates.value = props.modelValue != null ? [props.modelValue] : [];
} else {
selectedDates.value = [...props.date];
}
// 获取初始显示日期(优先使用选中日期,否则使用当前日期)
let [year, month] = dayUts(first(selectedDates.value)).toArray();
if (flag == true) {
year = props.year ?? year;
month = props.month ?? month;
}
currentYear.value = year;
currentMonth.value = month;
// 计算初始日历数据
calculateDateMatrix();
// 渲染日历视图
renderCalendar();
}
// 组件挂载时的初始化逻辑
onMounted(() => {
// 解析日期
parseDate(true);
// 监听单选模式的值变化
watch(
computed(() => props.modelValue ?? ""),
(newValue: string) => {
selectedDates.value = [newValue];
parseDate();
}
);
// 监听多选/范围模式的值变化
watch(
computed(() => props.date),
(newDateArray: string[]) => {
selectedDates.value = [...newDateArray];
parseDate();
}
);
// 重新渲染
watch(
computed(() => [props.dateConfig, props.showOtherMonth]),
() => {
calculateDateMatrix();
renderCalendar();
},
{
deep: true
}
);
});
</script>
<style lang="scss" scoped>
/* 日历组件主容器 */
.cl-calendar {
@apply relative;
/* 头部导航栏样式 */
&__header {
@apply flex flex-row items-center justify-between p-3 w-full;
/* 上一月/下一月按钮样式 */
&-prev,
&-next {
@apply flex flex-row items-center justify-center rounded-full bg-surface-100;
width: 60rpx;
height: 60rpx;
/* 暗色模式适配 */
&.is-dark {
@apply bg-surface-700;
}
}
/* 当前年月显示区域 */
&-date {
@apply flex flex-row items-center justify-center;
}
}
/* 星期标题行样式 */
&__weeks {
@apply flex flex-row;
/* 单个星期标题样式 */
&-item {
@apply flex flex-row items-center justify-center flex-1;
height: 80rpx;
}
}
/* 日期网格容器样式 */
&__view {
@apply w-full;
// #ifndef APP
/* 日期行样式 */
&-row {
@apply flex flex-row;
}
/* 日期单元格样式 */
&-cell {
@apply flex-1 flex flex-col items-center justify-center relative;
height: 80rpx;
/* 隐藏状态(相邻月份日期) */
&.is-hide {
opacity: 0;
}
/* 禁用状态 */
&.is-disabled {
@apply opacity-50;
}
}
// #endif
}
}
</style>