Переглянути джерело

添加 cl-calendar 组件

icssoa 6 місяців тому
батько
коміт
3deed8ff78

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "cool-unix",
-	"version": "8.0.22",
+	"version": "8.0.23",
 	"license": "MIT",
 	"scripts": {
 		"build-ui": "node ./uni_modules/cool-ui/scripts/generate-types.js",

+ 6 - 0
pages.json

@@ -215,6 +215,12 @@
 					}
 				},
 				{
+					"path": "form/calendar",
+					"style": {
+						"navigationBarTitleText": "Calendar 日历"
+					}
+				},
+				{
 					"path": "layout/flex",
 					"style": {
 						"navigationBarTitleText": "Flex 弹性布局"

+ 41 - 0
pages/demo/form/calendar.uvue

@@ -0,0 +1,41 @@
+<template>
+	<cl-page>
+		<view class="p-3">
+			<demo-item :label="t('选择器')">
+				<cl-calendar-select v-model="date"></cl-calendar-select>
+			</demo-item>
+
+			<demo-item :label="t('范围选择器')">
+				<cl-calendar-select v-model:date="dateArr" mode="range"></cl-calendar-select>
+			</demo-item>
+
+			<demo-item :label="t('禁用部分日期')">
+				<cl-calendar-select
+					v-model="date"
+					:disabled-date="disabledDate"
+				></cl-calendar-select>
+			</demo-item>
+
+			<demo-item :label="t('日历面板')">
+				<cl-calendar v-model="date"></cl-calendar>
+			</demo-item>
+		</view>
+	</cl-page>
+</template>
+
+<script lang="ts" setup>
+import { ref } from "vue";
+import { t } from "@/locale";
+import DemoItem from "../components/item.uvue";
+import { dayUts } from "@/cool";
+
+const date = ref<string | null>(dayUts().format("YYYY-MM-DD"));
+
+const dateArr = ref<string[]>(["2025-09-02", "2025-09-09"]);
+
+const disabledDate = ref<string[]>([
+	dayUts().add(2, "day").format("YYYY-MM-DD"),
+	dayUts().add(4, "day").format("YYYY-MM-DD"),
+	dayUts().add(6, "day").format("YYYY-MM-DD")
+]);
+</script>

+ 10 - 0
pages/index/home.uvue

@@ -226,6 +226,11 @@ const data = computed<Item[]>(() => {
 					label: t("文件上传"),
 					icon: "file-upload-line",
 					path: "/pages/demo/form/upload"
+				},
+				{
+					label: t("日历"),
+					icon: "calendar-line",
+					path: "/pages/demo/form/calendar"
 				}
 			]
 		},
@@ -327,6 +332,11 @@ const data = computed<Item[]>(() => {
 					label: t("树形结构"),
 					icon: "node-tree",
 					path: "/pages/demo/data/tree"
+				},
+				{
+					label: t("日历"),
+					icon: "calendar-line",
+					path: "/pages/demo/data/calendar"
 				}
 			]
 		},

+ 12 - 0
types/uni-app.d.ts

@@ -430,6 +430,17 @@ declare const onUnhandledRejection: (
 
 declare const onUnload: (hook: () => any, target?: ComponentInternalInstance | null) => void;
 
+declare interface DOMRect {
+	x: number;
+	y: number;
+	width: number;
+	height: number;
+	left: number;
+	top: number;
+	right: number;
+	bottom: number;
+}
+
 declare interface UniElement {
 	$vm: ComponentPublicInstance;
 	id: string;
@@ -446,6 +457,7 @@ declare interface UniElement {
 		success?: (res: { tempFilePath: string }) => void;
 		fail?: (err: { errCode: number; errMsg: string }) => void;
 	}): void;
+	getBoundingClientRectAsync(): Promise<DOMRect>;
 	getDrawableContext(): DrawableContext;
 	animate(
 		keyframes: UniAnimationKeyframe | UniAnimationKeyframe[],

+ 271 - 0
uni_modules/cool-ui/components/cl-calendar-select/cl-calendar-select.uvue

@@ -0,0 +1,271 @@
+<template>
+	<cl-select-trigger
+		v-if="showTrigger"
+		:pt="{
+			className: pt.trigger?.className,
+			icon: pt.trigger?.icon
+		}"
+		:placeholder="placeholder"
+		:disabled="disabled"
+		:focus="popupRef?.isOpen"
+		:text="text"
+		@open="open()"
+		@clear="clear"
+	></cl-select-trigger>
+
+	<cl-popup
+		ref="popupRef"
+		v-model="visible"
+		:title="title"
+		:pt="{
+			className: pt.popup?.className,
+			header: pt.popup?.header,
+			container: pt.popup?.container,
+			mask: pt.popup?.mask,
+			draw: pt.popup?.draw
+		}"
+	>
+		<view class="cl-select-popup" @touchmove.stop>
+			<slot name="prepend"></slot>
+
+			<view class="cl-select-popup__picker">
+				<cl-calendar
+					v-model="value"
+					v-model:date="date"
+					:mode="mode"
+					:disabled-date="disabledDate"
+				></cl-calendar>
+			</view>
+
+			<slot name="append"></slot>
+
+			<view class="cl-select-popup__op">
+				<cl-button
+					v-if="showCancel"
+					size="large"
+					text
+					border
+					type="light"
+					:pt="{
+						className: 'flex-1 !rounded-xl h-[80rpx]'
+					}"
+					@tap="close"
+					>{{ cancelText }}</cl-button
+				>
+				<cl-button
+					v-if="showConfirm"
+					size="large"
+					:pt="{
+						className: 'flex-1 !rounded-xl h-[80rpx]'
+					}"
+					@tap="confirm"
+					>{{ confirmText }}</cl-button
+				>
+			</view>
+		</view>
+	</cl-popup>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, type PropType } from "vue";
+import type { ClCalendarMode } from "../../types";
+import { isEmpty, parsePt } from "@/cool";
+import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
+import type { ClPopupPassThrough } from "../cl-popup/props";
+import { t } from "@/locale";
+
+defineOptions({
+	name: "cl-calendar-select"
+});
+
+defineSlots<{
+	prepend(): any;
+	append(): any;
+}>();
+
+// 组件属性定义
+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"
+	},
+	// 禁用的日期
+	disabledDate: {
+		type: Array as PropType<string[]>,
+		default: () => []
+	},
+	// 选择器标题
+	title: {
+		type: String,
+		default: () => t("请选择")
+	},
+	// 选择器占位符
+	placeholder: {
+		type: String,
+		default: () => t("请选择")
+	},
+	// 是否显示选择器触发器
+	showTrigger: {
+		type: Boolean,
+		default: true
+	},
+	// 是否禁用选择器
+	disabled: {
+		type: Boolean,
+		default: false
+	},
+	// 分隔符
+	splitor: {
+		type: String,
+		default: () => t(" 至 ")
+	},
+	// 确认按钮文本
+	confirmText: {
+		type: String,
+		default: () => t("确定")
+	},
+	// 是否显示确认按钮
+	showConfirm: {
+		type: Boolean,
+		default: true
+	},
+	// 取消按钮文本
+	cancelText: {
+		type: String,
+		default: () => t("取消")
+	},
+	// 是否显示取消按钮
+	showCancel: {
+		type: Boolean,
+		default: true
+	}
+});
+
+// 定义事件
+const emit = defineEmits(["update:modelValue", "update:date", "change"]);
+
+// 弹出层引用
+const popupRef = ref<ClPopupComponentPublicInstance | null>(null);
+
+// 透传样式类型定义
+type PassThrough = {
+	trigger?: ClSelectTriggerPassThrough;
+	popup?: ClPopupPassThrough;
+};
+
+// 解析透传样式配置
+const pt = computed(() => parsePt<PassThrough>(props.pt));
+
+// 当前选中的值
+const value = ref<string | null>(null);
+
+// 当前选中的日期
+const date = ref<string[]>([]);
+
+// 显示文本
+const text = computed(() => {
+	return props.mode == "single" ? (props.modelValue ?? "") : props.date.join(props.splitor);
+});
+
+// 选择器显示状态
+const visible = ref(false);
+
+// 选择回调函数
+let callback: ((value: string | string[]) => void) | null = null;
+
+// 打开选择器
+function open(cb: ((value: string | string[]) => void) | null = null) {
+	visible.value = true;
+
+	// 单选日期
+	value.value = props.modelValue;
+
+	// 多个日期
+	date.value = props.date;
+
+	// 回调
+	callback = cb;
+}
+
+// 关闭选择器
+function close() {
+	visible.value = false;
+}
+
+// 清空选择器
+function clear() {
+	value.value = null;
+	date.value = [] as string[];
+
+	emit("update:modelValue", value.value);
+	emit("update:date", date.value);
+}
+
+// 确认选择
+function confirm() {
+	if (props.mode == "single") {
+		if (value.value == null) {
+			return;
+		}
+
+		emit("update:modelValue", value.value);
+		emit("change", value.value);
+
+		// 触发回调
+		if (callback != null) {
+			callback!(value.value);
+		}
+	} else {
+		if (isEmpty(date.value)) {
+			return;
+		}
+
+		emit("update:date", date.value);
+		emit("change", date.value);
+
+		// 触发回调
+		if (callback != null) {
+			callback!(date.value);
+		}
+	}
+
+	// 关闭选择器
+	close();
+}
+
+defineExpose({
+	open,
+	close
+});
+</script>
+
+<style lang="scss" scoped>
+.cl-select {
+	&-popup {
+		&__picker {
+			@apply p-3 pt-0;
+		}
+
+		&__op {
+			@apply flex flex-row items-center justify-center;
+			padding: 24rpx;
+		}
+	}
+}
+</style>

+ 732 - 0
uni_modules/cool-ui/components/cl-calendar/cl-calendar.uvue

@@ -0,0 +1,732 @@
+<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` }">
+			<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="calendarViewRef"
+			:style="{ height: `${viewHeight}px`, gap: `${cellGap}px` }"
+			@tap="onTap"
+		>
+			<!-- Web端使用DOM渲染 -->
+			<!-- #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' }"
+					@click.stop="selectDateCell(dateCell)"
+				>
+					<cl-text :color="getCellColor(dateCell)" :size="`${fontSize}px`">{{
+						dateCell.date
+					}}</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, parsePt, useRefs } from "@/cool";
+import CalendarPicker from "./picker.uvue";
+import { $t, t } from "@/locale";
+import type { ClCalendarMode } from "../../types";
+
+defineOptions({
+	name: "cl-calendar"
+});
+
+// 日期单元格数据结构
+type DateCell = {
+	date: string; // 显示的日期数字
+	isCurrentMonth: boolean; // 是否属于当前显示月份
+	isToday: boolean; // 是否为今天
+	isSelected: boolean; // 是否被选中
+	isRange: boolean; // 是否在选择范围内
+	fullDate: string; // 完整日期格式 YYYY-MM-DD
+	isDisabled: boolean; // 是否被禁用
+	isHide: boolean; // 是否隐藏显示
+};
+
+// 组件属性定义
+const props = defineProps({
+	// 透传样式配置
+	pt: {
+		type: Object,
+		default: () => ({})
+	},
+	// 是否显示头部导航栏
+	showHeader: {
+		type: Boolean,
+		default: true
+	},
+	// 日期选择模式:单选/多选/范围选择
+	mode: {
+		type: String as PropType<ClCalendarMode>,
+		default: "single"
+	},
+	// 当前选中的日期值(单选模式)
+	modelValue: {
+		type: String as PropType<string | null>,
+		default: null
+	},
+	// 选中的日期数组(多选/范围模式)
+	date: {
+		type: Array as PropType<string[]>,
+		default: () => []
+	},
+	// 是否显示其他月份的日期
+	showOtherMonth: {
+		type: Boolean,
+		default: true
+	},
+	// 禁用的日期列表
+	disabledDate: {
+		type: Array as PropType<string[]>,
+		default: () => []
+	},
+	// 单元格高度
+	cellHeight: {
+		type: Number,
+		default: 60
+	},
+	// 单元格间距
+	cellGap: {
+		type: Number,
+		default: 0
+	},
+	// 字体大小
+	fontSize: {
+		type: Number,
+		default: 14
+	},
+	// 当前月日期颜色
+	currentMonthColor: {
+		type: String,
+		default: () => ctx.color["surface-700"] as string
+	},
+	// 其他月日期颜色
+	otherMonthColor: {
+		type: String,
+		default: () => ctx.color["surface-300"] as string
+	},
+	// 今天日期颜色
+	todayColor: {
+		type: String,
+		default: "#ff6b6b"
+	},
+	// 选中日期文字颜色
+	selectedTextColor: {
+		type: String,
+		default: "#ffffff"
+	},
+	// 选中日期背景色
+	selectedBgColor: {
+		type: String,
+		default: () => ctx.color["primary-500"] as string
+	},
+	// 范围选择背景色
+	rangeBgColor: {
+		type: String,
+		default: () => ctx.color["primary-100"] as string
+	},
+	// 禁用的日期颜色
+	disabledColor: {
+		type: String,
+		default: () => ctx.color["surface-300"] as string
+	}
+});
+
+// 事件发射器定义
+const emit = defineEmits(["update:modelValue", "update:date", "change"]);
+
+// 透传样式属性类型
+type PassThrough = {
+	className?: string;
+};
+
+// 解析透传样式配置
+const pt = computed(() => parsePt<PassThrough>(props.pt));
+
+// 组件引用管理器
+const refs = useRefs();
+
+// 日历视图DOM元素引用
+const calendarViewRef = ref<UniElement | null>(null);
+
+// 当前显示的年份
+const currentYear = ref(0);
+
+// 当前显示的月份
+const currentMonth = ref(0);
+
+// 视图高度
+const viewHeight = computed(() => {
+	return props.cellHeight * 6 + "px";
+});
+
+// 单元格宽度
+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 calendarViewRef.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 {
+	return props.disabledDate.includes(dateStr);
+}
+
+/**
+ * 判断指定日期是否在选择范围内(不包括端点)
+ * @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);
+}
+
+/**
+ * 计算并生成日历矩阵数据
+ * 生成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 dateCell = {
+				date: `${dayNumber}`,
+				isCurrentMonth: belongsToCurrentMonth,
+				isToday: fullDateStr == todayStr,
+				isSelected: isDateSelected(fullDateStr),
+				isRange: isDateInRange(fullDateStr),
+				fullDate: fullDateStr,
+				isDisabled: isDateDisabled(fullDateStr),
+				isHide: false
+			} 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 renderCalendarCanvas() {
+	// #ifdef APP
+	await nextTick(); // 等待DOM更新完成
+
+	const canvasContext = calendarViewRef.value!.getDrawableContext();
+
+	if (canvasContext == null) return;
+
+	canvasContext!.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 * props.cellHeight;
+		const centerX = cellX + cellWidth.value / 2;
+		const centerY = cellY + props.cellHeight / 2;
+
+		// 绘制背景(选中状态或范围状态)
+		if (dateCell.isSelected || dateCell.isRange) {
+			const padding = props.cellGap; // 使用间距作为内边距
+			const bgX = cellX + padding;
+			const bgY = cellY + padding;
+			const bgWidth = cellWidth.value - padding * 2;
+			const bgHeight = props.cellHeight - padding * 2;
+
+			// 设置背景颜色
+			if (dateCell.isSelected) {
+				canvasContext!.fillStyle = props.selectedBgColor;
+			}
+			if (dateCell.isRange) {
+				canvasContext!.fillStyle = props.rangeBgColor;
+			}
+
+			canvasContext!.fillRect(bgX, bgY, bgWidth, bgHeight); // 绘制背景矩形
+		}
+
+		// 设置文字样式
+		canvasContext!.font = `${props.fontSize}px sans-serif`;
+		canvasContext!.textAlign = "center";
+
+		// 根据状态设置文字颜色
+		if (dateCell.isSelected) {
+			canvasContext!.fillStyle = props.selectedTextColor;
+		} else if (dateCell.isToday) {
+			canvasContext!.fillStyle = props.todayColor;
+		} else if (dateCell.isCurrentMonth) {
+			canvasContext!.fillStyle = props.currentMonthColor;
+		} else {
+			canvasContext!.fillStyle = props.otherMonthColor;
+		}
+
+		// 绘制日期数字(垂直居中对齐)
+		const textOffsetY = (props.fontSize / 2) * 0.7;
+		canvasContext!.fillText(dateCell.date.toString(), centerX, centerY + textOffsetY);
+	}
+
+	// 获取容器尺寸信息
+	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];
+			drawSingleCell(dateCell, colIndex, rowIndex);
+		}
+	}
+
+	canvasContext!.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();
+	renderCalendarCanvas();
+}
+
+/**
+ * 获取单元格字体颜色
+ * @param dateCell 日期单元格数据
+ * @returns 字体颜色
+ */
+function getCellColor(dateCell: DateCell): string {
+	// 禁用的日期颜色
+	if (dateCell.isDisabled) {
+		return props.disabledColor;
+	}
+
+	// 选中的日期文字颜色
+	if (dateCell.isSelected) {
+		return props.selectedTextColor;
+	}
+
+	// 今天日期颜色
+	if (dateCell.isToday) {
+		return props.todayColor;
+	}
+
+	// 当前月份日期颜色
+	if (dateCell.isCurrentMonth) {
+		return props.currentMonthColor;
+	}
+
+	// 其他月份日期颜色
+	return props.otherMonthColor;
+}
+
+/**
+ * 处理年月选择器的变化事件
+ * @param yearMonthArray [年份, 月份] 数组
+ */
+function onYearMonthChange(yearMonthArray: number[]) {
+	console.log("年月选择器变化:", yearMonthArray);
+	currentYear.value = yearMonthArray[0];
+	currentMonth.value = yearMonthArray[1];
+
+	// 重新计算日历数据并重绘
+	calculateDateMatrix();
+	renderCalendarCanvas();
+}
+
+/**
+ * 处理点击事件(APP端Canvas点击检测)
+ */
+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 / props.cellHeight);
+
+	// 边界检查:确保索引在有效范围内
+	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();
+	renderCalendarCanvas();
+}
+
+/**
+ * 切换到下一个月
+ */
+function gotoNextMonth() {
+	const [newYear, newMonth] = dayUts(`${currentYear.value}-${currentMonth.value}-01`)
+		.add(1, "month")
+		.toArray();
+
+	currentYear.value = newYear;
+	currentMonth.value = newMonth;
+
+	// 重新计算并渲染日历
+	calculateDateMatrix();
+	renderCalendarCanvas();
+}
+
+/**
+ * 解析选中日期
+ */
+function parseDate() {
+	// 根据选择模式初始化选中日期
+	if (props.mode == "single") {
+		selectedDates.value = props.modelValue != null ? [props.modelValue] : [];
+	} else {
+		selectedDates.value = [...props.date];
+	}
+
+	// 获取初始显示日期(优先使用选中日期,否则使用当前日期)
+	const initialDate = first(selectedDates.value);
+	const [initialYear, initialMonth] = dayUts(initialDate).toArray();
+
+	currentYear.value = initialYear;
+	currentMonth.value = initialMonth;
+
+	// 计算初始日历数据
+	calculateDateMatrix();
+
+	// 渲染日历视图
+	renderCalendarCanvas();
+}
+
+// 组件挂载时的初始化逻辑
+onMounted(() => {
+	// 监听单选模式的值变化
+	watch(
+		computed(() => props.modelValue ?? ""),
+		(newValue: string) => {
+			selectedDates.value = [newValue];
+			parseDate();
+		},
+		{
+			immediate: true
+		}
+	);
+
+	// 监听多选/范围模式的值变化
+	watch(
+		computed(() => props.date),
+		(newDateArray: string[]) => {
+			selectedDates.value = [...newDateArray];
+			parseDate();
+		},
+		{
+			immediate: 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;
+
+		/* Web端DOM渲染样式 */
+		// #ifndef APP
+		/* 日期行样式 */
+		&-row {
+			@apply flex flex-row;
+		}
+
+		/* 日期单元格样式 */
+		&-cell {
+			@apply flex-1 flex flex-col items-center justify-center relative;
+			height: 80rpx;
+
+			/* 选中状态样式 */
+			&.is-selected {
+				background-color: v-bind("props.selectedBgColor");
+			}
+
+			/* 范围选择背景样式 */
+			&.is-range {
+				background-color: v-bind("props.rangeBgColor");
+			}
+
+			/* 隐藏状态(相邻月份日期) */
+			&.is-hide {
+				opacity: 0;
+			}
+
+			/* 禁用状态 */
+			&.is-disabled {
+				@apply opacity-50;
+			}
+		}
+		// #endif
+	}
+}
+</style>

+ 273 - 0
uni_modules/cool-ui/components/cl-calendar/picker.uvue

@@ -0,0 +1,273 @@
+<template>
+	<view
+		class="cl-calendar-picker"
+		:class="{
+			'is-dark': isDark
+		}"
+		v-if="visible"
+	>
+		<view class="cl-calendar-picker__header">
+			<view
+				class="cl-calendar-picker__prev"
+				:class="{
+					'is-dark': isDark
+				}"
+				@tap="prev"
+			>
+				<cl-icon name="arrow-left-double-line"></cl-icon>
+			</view>
+
+			<view class="cl-calendar-picker__date" @tap="toMode('year')">
+				<cl-text
+					:pt="{
+						className: 'text-lg'
+					}"
+				>
+					{{ title }}
+				</cl-text>
+			</view>
+
+			<view
+				class="cl-calendar-picker__next"
+				:class="{
+					'is-dark': isDark
+				}"
+				@tap="next"
+			>
+				<cl-icon name="arrow-right-double-line"></cl-icon>
+			</view>
+		</view>
+
+		<view class="cl-calendar-picker__list">
+			<view
+				class="cl-calendar-picker__item"
+				v-for="item in list"
+				:key="item.value"
+				@tap="select(item.value)"
+			>
+				<cl-text
+					:pt="{
+						className: parseClass([[item.value == value, 'text-primary-500']])
+					}"
+					>{{ item.label }}</cl-text
+				>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script lang="ts" setup>
+import { first, isDark, last, parseClass } from "@/cool";
+import { t } from "@/locale";
+import { computed, ref } from "vue";
+
+defineOptions({
+	name: "cl-calendar-picker"
+});
+
+// 定义日历选择器的条目类型
+type Item = {
+	label: string; // 显示的标签,如"1月"、"2024"
+	value: number; // 对应的数值,如1、2024
+};
+
+// 定义组件接收的属性:年份和月份,均为数字类型,默认值为0
+const props = defineProps({
+	year: {
+		type: Number,
+		default: 0
+	},
+	month: {
+		type: Number,
+		default: 0
+	}
+});
+
+// 定义组件可触发的事件,这里只定义了"change"事件
+const emit = defineEmits(["change"]);
+
+// 当前选择的模式,"year"表示选择年份,"month"表示选择月份,默认是"month"
+const mode = ref<"year" | "month">("month");
+
+// 当前选中的年份
+const year = ref(0);
+
+// 当前选中的月份
+const month = ref(0);
+
+// 当前年份选择面板的起始年份(如2020-2029,则startYear为2020)
+const startYear = ref(0);
+
+// 当前选中的值,若为月份模式则为月份,否则为年份
+const value = computed(() => {
+	return mode.value == "month" ? month.value : year.value;
+});
+
+// 计算可供选择的列表:
+// - 若为月份模式,返回1-12月
+// - 若为年份模式,返回以startYear为起点的连续10年
+const list = computed(() => {
+	if (mode.value == "month") {
+		return [
+			{ label: t("1月"), value: 1 },
+			{ label: t("2月"), value: 2 },
+			{ label: t("3月"), value: 3 },
+			{ label: t("4月"), value: 4 },
+			{ label: t("5月"), value: 5 },
+			{ label: t("6月"), value: 6 },
+			{ label: t("7月"), value: 7 },
+			{ label: t("8月"), value: 8 },
+			{ label: t("9月"), value: 9 },
+			{ label: t("10月"), value: 10 },
+			{ label: t("11月"), value: 11 },
+			{ label: t("12月"), value: 12 }
+		] as Item[];
+	} else {
+		const years: Item[] = [];
+		// 生成10个连续年份
+		for (let i = 0; i < 10; i++) {
+			years.push({
+				label: `${startYear.value + i}`,
+				value: startYear.value + i
+			});
+		}
+		return years;
+	}
+});
+
+// 计算标题内容:
+// - 月份模式下显示“xxxx年”
+// - 年份模式下显示“起始年 - 结束年”
+const title = computed(() => {
+	return mode.value == "month"
+		? `${year.value}年`
+		: `${first(list.value)?.label} - ${last(list.value)?.label}`;
+});
+
+// 控制选择器弹窗的显示与隐藏
+const visible = ref(false);
+
+/**
+ * 打开选择器,并初始化年份、月份、起始年份
+ */
+function open() {
+	visible.value = true;
+
+	// 初始化当前年份和月份为传入的props
+	year.value = props.year;
+	month.value = props.month;
+
+	// 计算当前年份所在的十年区间的起始年份
+	startYear.value = Math.floor(year.value / 10) * 10;
+}
+
+/**
+ * 关闭选择器
+ */
+function close() {
+	visible.value = false;
+}
+
+/**
+ * 切换选择模式(年份/月份)
+ * @param val "year" 或 "month"
+ */
+function toMode(val: "year" | "month") {
+	mode.value = val;
+}
+
+/**
+ * 选择某个值(年份或月份)
+ * @param val 选中的值
+ */
+function select(val: number) {
+	if (mode.value == "month") {
+		// 选择月份后,关闭弹窗并触发change事件
+		month.value = val;
+		close();
+		emit("change", [year.value, month.value]);
+	} else {
+		// 选择年份后,切换到月份选择模式
+		year.value = val;
+		toMode("month");
+	}
+}
+
+/**
+ * 切换到上一个区间
+ * - 月份模式下,年份减1
+ * - 年份模式下,起始年份减10
+ */
+function prev() {
+	if (mode.value == "month") {
+		year.value -= 1;
+	} else {
+		startYear.value -= 10;
+	}
+}
+
+/**
+ * 切换到下一个区间
+ * - 月份模式下,年份加1
+ * - 年份模式下,起始年份加10
+ */
+function next() {
+	if (mode.value == "month") {
+		year.value += 1;
+	} else {
+		startYear.value += 10;
+	}
+}
+
+defineExpose({
+	open,
+	close
+});
+</script>
+
+<style lang="scss" scoped>
+.cl-calendar-picker {
+	@apply flex flex-col absolute left-0 top-0 w-full h-full bg-white z-10;
+
+	&__header {
+		@apply flex flex-row items-center justify-between w-full p-3;
+	}
+
+	&__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;
+	}
+
+	&__list {
+		@apply flex flex-row flex-wrap;
+	}
+
+	&__item {
+		@apply flex flex-row items-center justify-center;
+		height: 100rpx;
+		width: 25%;
+
+		&-bg {
+			@apply px-4 py-2;
+
+			&.is-active {
+				@apply bg-primary-500 rounded-xl;
+			}
+		}
+	}
+
+	&.is-dark {
+		@apply bg-surface-800;
+	}
+}
+</style>

+ 1 - 1
uni_modules/cool-ui/components/cl-select-date/cl-select-date.uvue

@@ -252,7 +252,7 @@ const props = defineProps({
 	// 范围分隔符
 	rangeSeparator: {
 		type: String,
-		default: () => t("至")
+		default: () => t("  ")
 	},
 	// 是否显示快捷选项
 	showShortcuts: {

+ 2 - 0
uni_modules/cool-ui/types/index.ts

@@ -198,3 +198,5 @@ export type ClTreeNodeInfo = {
 	parent?: ClTreeItem;
 	index: number;
 };
+
+export type ClCalendarMode = "single" | "multiple" | "range";