Vue 日期选择器:Day.js、vant-datepicker 深度指南
【摘要】 一、引言1.1 日期选择器的重要性在现代Web应用中,日期选择是用户交互的核心组件之一。从简单的生日选择到复杂的日程安排,日期选择器的用户体验和功能性直接影响应用质量。1.2 技术选型对比分析class DatePickerComparison { constructor() { this.comparison = { 'Day.js + 原生Vue...
一、引言
1.1 日期选择器的重要性
1.2 技术选型对比分析
class DatePickerComparison {
constructor() {
this.comparison = {
'Day.js + 原生Vue': {
'包大小': '2KB + 自定义组件',
'灵活性': '⭐⭐⭐⭐⭐ (完全可控)',
'复杂度': '高 (需要自行开发)',
'定制性': '无限定制可能',
'适用场景': '高度定制化需求、特殊交互'
},
'Vant DatePicker': {
'包大小': '15KB (包含样式)',
'灵活性': '⭐⭐⭐ (配置丰富)',
'复杂度': '低 (开箱即用)',
'定制性': '中等 (主题可配置)',
'适用场景': '移动端、快速开发、一致性UI'
},
'Element Plus DatePicker': {
'包大小': '25KB (包含依赖)',
'灵活性': '⭐⭐⭐⭐ (功能全面)',
'复杂度': '中 (学习曲线平缓)',
'定制性': '高 (API丰富)',
'适用场景': 'PC端、管理系统、企业应用'
}
};
}
getSelectionGuide(requirements) {
return {
'选择Day.js+自定义当': [
'项目有特殊设计需求',
'对包大小极度敏感',
'需要特殊交互逻辑',
'已有设计系统需要集成'
],
'选择Vant DatePicker当': [
'移动端H5应用开发',
'追求开发效率',
'需要一致的移动端体验',
'项目使用Vant组件库'
],
'选择Element Plus当': [
'PC端管理系统',
'需要丰富日期功能',
'团队熟悉Element',
'企业级应用开发'
]
};
}
}
1.3 性能基准对比
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
二、技术背景
2.1 日期处理技术演进
graph TB
A[日期处理技术演进] --> B[原生Date对象]
A --> C[Moment.js时代]
A --> D[轻量级替代]
A --> E[现代化方案]
B --> B1[功能有限]
B --> B2[时区处理复杂]
C --> C1[功能全面但笨重]
C --> C2[2.29MB包大小]
D --> D1[Day.js 2KB]
D --> D2[date-fns 模块化]
E --> E1[Tree-shaking优化]
E --> E2[不可变数据]
E --> E3[TypeScript支持]
D1 --> F[Vue日期选择生态]
E1 --> F
F --> G[Day.js集成]
F --> H[Vant DatePicker]
F --> I[Element Plus]
2.2 核心日期处理概念
class DateConcepts {
constructor() {
this.concepts = {
'日期格式化': {
'ISO 8601': 'YYYY-MM-DDTHH:mm:ssZ',
'本地化格式': '根据地区自动适配',
'自定义格式': '灵活的输出控制'
},
'时区处理': {
'UTC': '协调世界时',
'本地时区': '用户所在时区',
'时区转换': '不同时区间转换'
},
'日期计算': {
'相对时间': '几天前、几分钟后',
'日期差值': '计算两个日期间隔',
'工作日计算': '排除周末节假日'
},
'本地化': {
'语言支持': '多语言日期显示',
'周起始日': '周一或周日开始',
'日期格式': '月/日/年 顺序差异'
};
}
getCommonPatterns() {
return {
'日期解析': [
'字符串 -> Date对象',
'时间戳 -> 格式化日期',
'相对时间 -> 绝对时间'
],
'日期验证': [
'合法性检查(2月30日)',
'范围限制(最小/最大日期)',
'格式验证(输入格式检查)'
],
'日期操作': [
'加减天数/月数/年数',
'设置特定时间部分',
'日期比较和排序'
]
};
}
}
三、环境准备与项目配置
3.1 安装与基础配置
// package.json 依赖配置
{
"dependencies": {
"vue": "^3.3.0",
"dayjs": "^1.11.0",
"vant": "^4.0.0",
"element-plus": "^2.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"vite": "^4.0.0",
"unplugin-vue-components": "^0.25.0"
}
}
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
import { VantResolver, ElementPlusResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig({
plugins: [
vue(),
Components({
resolvers: [VantResolver(), ElementPlusResolver()]
})
],
optimizeDeps: {
include: ['dayjs', 'vant', 'element-plus']
}
});
3.2 Day.js 配置
// src/utils/dayjs-config.js
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn'; // 中文
import 'dayjs/locale/en'; // 英文
import advancedFormat from 'dayjs/plugin/advancedFormat';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import isTomorrow from 'dayjs/plugin/isTomorrow';
import relativeTime from 'dayjs/plugin/relativeTime';
import duration from 'dayjs/plugin/duration';
// 安装插件
dayjs.extend(advancedFormat);
dayjs.extend(weekOfYear);
dayjs.extend(isToday);
dayjs.extend(isYesterday);
dayjs.extend(isTomorrow);
dayjs.extend(relativeTime);
dayjs.extend(duration);
// 设置默认语言
dayjs.locale('zh-cn');
// 常用格式化常量
export const DATE_FORMATS = {
DATE: 'YYYY-MM-DD',
DATETIME: 'YYYY-MM-DD HH:mm:ss',
TIME: 'HH:mm:ss',
HUMAN: 'MMM D, YYYY',
FULL: 'dddd, MMMM D, YYYY'
};
// 工具函数
export class DateUtils {
// 获取日期范围
static getDateRange(days = 7, startDate = dayjs()) {
return Array.from({ length: days }, (_, i) =>
startDate.subtract(i, 'day').format('YYYY-MM-DD')
).reverse();
}
// 计算工作日
static calculateWorkdays(start, end, holidays = []) {
let count = 0;
let current = dayjs(start);
const endDate = dayjs(end);
while (current.isBefore(endDate) || current.isSame(endDate)) {
const day = current.day();
// 排除周末和节假日
if (day !== 0 && day !== 6 && !holidays.includes(current.format('YYYY-MM-DD'))) {
count++;
}
current = current.add(1, 'day');
}
return count;
}
// 相对时间显示
static getRelativeTime(date) {
const now = dayjs();
const target = dayjs(date);
const diffMinutes = now.diff(target, 'minute');
if (diffMinutes < 1) return '刚刚';
if (diffMinutes < 60) return `${diffMinutes}分钟前`;
if (diffMinutes < 1440) return `${Math.floor(diffMinutes / 60)}小时前`;
if (diffMinutes < 43200) return `${Math.floor(diffMinutes / 1440)}天前`;
return target.format('YYYY-MM-DD');
}
}
export default dayjs;
四、Day.js + 自定义日期选择器
4.1 基础日期选择器组件
<template>
<div class="custom-date-picker">
<!-- 输入框 -->
<div
class="date-input"
:class="{ 'is-focused': isOpen, 'has-error': hasError }"
@click="togglePicker"
>
<span class="prefix-icon">📅</span>
<input
ref="inputRef"
v-model="displayValue"
type="text"
readonly
:placeholder="placeholder"
class="input-field"
@blur="handleBlur"
/>
<span class="suffix-icon" @click="handleClear">
{{ modelValue ? '×' : '▼' }}
</span>
</div>
<!-- 下拉选择面板 -->
<transition name="date-picker-slide">
<div v-show="isOpen" class="date-panel">
<!-- 头部控制 -->
<div class="panel-header">
<button class="nav-btn" @click="prevMonth">‹</button>
<span class="current-month">{{ currentMonthText }}</span>
<button class="nav-btn" @click="nextMonth">›</button>
</div>
<!-- 星期标题 -->
<div class="week-days">
<span
v-for="day in weekDays"
:key="day"
class="week-day"
>
{{ day }}
</span>
</div>
<!-- 日期网格 -->
<div class="date-grid">
<div
v-for="(date, index) in calendarDays"
:key="index"
class="date-cell"
:class="getDateCellClass(date)"
@click="selectDate(date)"
>
<span class="date-number">{{ date.date() }}</span>
<span v-if="isToday(date)" class="today-marker">今</span>
</div>
</div>
<!-- 底部操作 -->
<div class="panel-footer">
<button class="footer-btn" @click="selectToday">今天</button>
<button class="footer-btn" @click="clearSelection">清除</button>
<button class="footer-btn primary" @click="confirmSelection">确定</button>
</div>
</div>
</transition>
<!-- 遮罩层 -->
<div v-show="isOpen" class="picker-overlay" @click="closePicker"></div>
</div>
</template>
<script>
import { ref, computed, watch, nextTick } from 'vue';
import dayjs from '@/utils/dayjs-config';
export default {
name: 'CustomDatePicker',
props: {
modelValue: {
type: [String, Date],
default: ''
},
placeholder: {
type: String,
default: '请选择日期'
},
format: {
type: String,
default: 'YYYY-MM-DD'
},
minDate: {
type: [String, Date],
default: null
},
maxDate: {
type: [String, Date],
default: null
},
disabledDate: {
type: Function,
default: () => false
}
},
emits: ['update:modelValue', 'change', 'open', 'close'],
setup(props, { emit }) {
const isOpen = ref(false);
const inputRef = ref(null);
const currentMonth = ref(dayjs());
const selectedDate = ref(null);
// 星期标题
const weekDays = computed(() => ['日', '一', '二', '三', '四', '五', '六']);
// 显示值
const displayValue = computed(() => {
if (!selectedDate.value) return '';
return selectedDate.value.format(props.format);
});
// 当前月份文本
const currentMonthText = computed(() => {
return currentMonth.value.format('YYYY年MM月');
});
// 验证错误
const hasError = computed(() => {
if (!selectedDate.value) return false;
const date = selectedDate.value;
const min = props.minDate ? dayjs(props.minDate) : null;
const max = props.maxDate ? dayjs(props.maxDate) : null;
if (min && date.isBefore(min, 'day')) return true;
if (max && date.isAfter(max, 'day')) return true;
return props.disabledDate(date);
});
// 生成日历天数
const calendarDays = computed(() => {
const days = [];
const firstDay = currentMonth.value.startOf('month');
const startDay = firstDay.startOf('week'); // 从周日开始
const endDay = currentMonth.value.endOf('month').endOf('week');
let currentDay = startDay;
while (currentDay.isBefore(endDay) || currentDay.isSame(endDay, 'day')) {
days.push(currentDay);
currentDay = currentDay.add(1, 'day');
}
return days;
});
// 获取日期单元格样式
const getDateCellClass = (date) => {
const classes = [];
if (!date.isSame(currentMonth.value, 'month')) {
classes.push('other-month');
}
if (selectedDate.value && date.isSame(selectedDate.value, 'day')) {
classes.push('selected');
}
if (isToday(date)) {
classes.push('today');
}
if (isDisabled(date)) {
classes.push('disabled');
}
return classes;
};
// 检查是否今天
const isToday = (date) => {
return date.isToday();
};
// 检查是否禁用
const isDisabled = (date) => {
const min = props.minDate ? dayjs(props.minDate) : null;
const max = props.maxDate ? dayjs(props.maxDate) : null;
if (min && date.isBefore(min, 'day')) return true;
if (max && date.isAfter(max, 'day')) return true;
return props.disabledDate(date);
};
// 切换选择器
const togglePicker = async () => {
isOpen.value = !isOpen.value;
if (isOpen.value) {
emit('open');
await nextTick();
// 滚动到选中日期
scrollToSelectedDate();
} else {
emit('close');
}
};
// 关闭选择器
const closePicker = () => {
isOpen.value = false;
emit('close');
};
// 选择日期
const selectDate = (date) => {
if (isDisabled(date)) return;
selectedDate.value = date;
};
// 确认选择
const confirmSelection = () => {
if (selectedDate.value && !hasError.value) {
emit('update:modelValue', selectedDate.value.toDate());
emit('change', selectedDate.value.toDate());
}
closePicker();
};
// 选择今天
const selectToday = () => {
const today = dayjs();
if (!isDisabled(today)) {
selectedDate.value = today;
}
};
// 清除选择
const clearSelection = () => {
selectedDate.value = null;
emit('update:modelValue', null);
emit('change', null);
};
// 上个月
const prevMonth = () => {
currentMonth.value = currentMonth.value.subtract(1, 'month');
};
// 下个月
const nextMonth = () => {
currentMonth.value = currentMonth.value.add(1, 'month');
};
// 处理输入框失去焦点
const handleBlur = (event) => {
// 延迟关闭以避免点击选择时立即关闭
setTimeout(() => {
if (!event.relatedTarget || !event.relatedTarget.closest('.date-panel')) {
closePicker();
}
}, 100);
};
// 处理清除
const handleClear = (event) => {
event.stopPropagation();
if (selectedDate.value) {
clearSelection();
} else {
togglePicker();
}
};
// 滚动到选中日期
const scrollToSelectedDate = () => {
// 实现滚动逻辑
};
// 监听值变化
watch(() => props.modelValue, (newValue) => {
if (newValue) {
selectedDate.value = dayjs(newValue);
currentMonth.value = selectedDate.value.startOf('month');
} else {
selectedDate.value = null;
}
}, { immediate: true });
return {
isOpen,
inputRef,
currentMonth,
selectedDate,
weekDays,
displayValue,
currentMonthText,
hasError,
calendarDays,
togglePicker,
closePicker,
selectDate,
confirmSelection,
selectToday,
clearSelection,
prevMonth,
nextMonth,
handleBlur,
handleClear,
getDateCellClass,
isToday
};
}
};
</script>
<style scoped>
.custom-date-picker {
position: relative;
display: inline-block;
}
.date-input {
position: relative;
display: flex;
align-items: center;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 8px 12px;
background: white;
cursor: pointer;
transition: all 0.3s;
}
.date-input.is-focused {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.date-input.has-error {
border-color: #f56c6c;
}
.prefix-icon {
margin-right: 8px;
font-size: 16px;
}
.input-field {
flex: 1;
border: none;
outline: none;
background: transparent;
cursor: pointer;
font-size: 14px;
}
.input-field::placeholder {
color: #c0c4cc;
}
.suffix-icon {
margin-left: 8px;
padding: 4px;
cursor: pointer;
border-radius: 50%;
transition: background 0.3s;
}
.suffix-icon:hover {
background: #f5f5f5;
}
.date-panel {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
background: white;
border: 1px solid #e4e7ed;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
margin-top: 4px;
min-width: 280px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-bottom: 1px solid #e4e7ed;
}
.nav-btn {
border: none;
background: none;
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.3s;
}
.nav-btn:hover {
background: #f5f5f5;
}
.current-month {
font-weight: 600;
color: #303133;
}
.week-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
}
.week-day {
text-align: center;
font-size: 12px;
color: #909399;
}
.date-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
padding: 8px;
}
.date-cell {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 32px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.date-cell:hover:not(.disabled) {
background: #f0f0f0;
}
.date-cell.selected {
background: #409eff;
color: white;
}
.date-cell.today {
color: #409eff;
font-weight: 600;
}
.date-cell.today.selected {
color: white;
}
.date-cell.other-month {
color: #c0c4cc;
}
.date-cell.disabled {
color: #c0c4cc;
cursor: not-allowed;
background: #f5f5f5;
}
.today-marker {
position: absolute;
top: -2px;
right: -2px;
background: #409eff;
color: white;
border-radius: 50%;
width: 14px;
height: 14px;
font-size: 10px;
line-height: 14px;
text-align: center;
}
.panel-footer {
display: flex;
justify-content: space-between;
padding: 12px;
border-top: 1px solid #e4e7ed;
}
.footer-btn {
padding: 6px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: white;
cursor: pointer;
transition: all 0.3s;
}
.footer-btn:hover {
border-color: #c0c4cc;
}
.footer-btn.primary {
background: #409eff;
color: white;
border-color: #409eff;
}
.footer-btn.primary:hover {
background: #66b1ff;
border-color: #66b1ff;
}
.picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
/* 动画效果 */
.date-picker-slide-enter-active,
.date-picker-slide-leave-active {
transition: all 0.3s;
transform-origin: top center;
}
.date-picker-slide-enter-from {
opacity: 0;
transform: scaleY(0.8);
}
.date-picker-slide-leave-to {
opacity: 0;
transform: scaleY(0.8);
}
</style>
4.2 高级日期范围选择器
<template>
<div class="date-range-picker">
<!-- 范围输入框 -->
<div class="range-input" @click="togglePicker">
<div class="input-group">
<input
v-model="startDisplay"
type="text"
readonly
placeholder="开始日期"
class="range-field"
:class="{ 'is-active': activeInput === 'start' }"
@focus="setActiveInput('start')"
/>
<span class="separator">至</span>
<input
v-model="endDisplay"
type="text"
readonly
placeholder="结束日期"
class="range-field"
:class="{ 'is-active': activeInput === 'end' }"
@focus="setActiveInput('end')"
/>
</div>
<span class="suffix-icon" @click="handleClear">×</span>
</div>
<!-- 范围选择面板 -->
<div v-show="isOpen" class="range-panel">
<div class="panel-content">
<!-- 双月视图 -->
<div class="month-views">
<div class="month-view">
<div class="month-header">
<button class="nav-btn" @click="prevYear(leftMonth)">«</button>
<button class="nav-btn" @click="prevMonth(leftMonth)">‹</button>
<span class="month-title">{{ leftMonthText }}</span>
</div>
<CalendarMonth
:month="leftMonth"
:start-date="startDate"
:end-date="endDate"
:active-input="activeInput"
@date-select="handleDateSelect"
/>
</div>
<div class="month-view">
<div class="month-header">
<span class="month-title">{{ rightMonthText }}</span>
<button class="nav-btn" @click="nextMonth(rightMonth)">›</button>
<button class="nav-btn" @click="nextYear(rightMonth)">»</button>
</div>
<CalendarMonth
:month="rightMonth"
:start-date="startDate"
:end-date="endDate"
:active-input="activeInput"
@date-select="handleDateSelect"
/>
</div>
</div>
<!-- 快捷选择 -->
<div class="quick-selection">
<h4>快捷选择</h4>
<div class="quick-buttons">
<button
v-for="option in quickOptions"
:key="option.label"
class="quick-btn"
@click="selectQuickRange(option)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<!-- 底部操作 -->
<div class="panel-footer">
<div class="selected-info">
<span v-if="selectedDays > 0">已选 {{ selectedDays }} 天</span>
</div>
<div class="action-buttons">
<button class="action-btn" @click="clearSelection">清除</button>
<button class="action-btn primary" @click="confirmSelection">确定</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, watch } from 'vue';
import dayjs from '@/utils/dayjs-config';
import CalendarMonth from './CalendarMonth.vue';
export default {
name: 'DateRangePicker',
components: { CalendarMonth },
props: {
modelValue: {
type: Object,
default: () => ({ start: null, end: null })
},
format: {
type: String,
default: 'YYYY-MM-DD'
},
maxRange: {
type: Number,
default: 365 // 最大选择天数
}
},
emits: ['update:modelValue', 'change'],
setup(props, { emit }) {
const isOpen = ref(false);
const activeInput = ref('start'); // 'start' or 'end'
const startDate = ref(null);
const endDate = ref(null);
const leftMonth = ref(dayjs().startOf('month'));
const rightMonth = ref(dayjs().add(1, 'month').startOf('month'));
// 显示值
const startDisplay = computed(() =>
startDate.value ? startDate.value.format(props.format) : ''
);
const endDisplay = computed(() =>
endDate.value ? endDate.value.format(props.format) : ''
);
// 月份显示文本
const leftMonthText = computed(() => leftMonth.value.format('YYYY年MM月'));
const rightMonthText = computed(() => rightMonth.value.format('YYYY年MM月'));
// 选中天数
const selectedDays = computed(() => {
if (!startDate.value || !endDate.value) return 0;
return endDate.value.diff(startDate.value, 'day') + 1;
});
// 快捷选择选项
const quickOptions = computed(() => [
{ label: '今天', getRange: () => [dayjs(), dayjs()] },
{ label: '昨天', getRange: () => [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] },
{ label: '最近7天', getRange: () => [dayjs().subtract(6, 'day'), dayjs()] },
{ label: '最近30天', getRange: () => [dayjs().subtract(29, 'day'), dayjs()] },
{ label: '本月', getRange: () => [dayjs().startOf('month'), dayjs().endOf('month')] },
{ label: '上个月', getRange: () => [
dayjs().subtract(1, 'month').startOf('month'),
dayjs().subtract(1, 'month').endOf('month')
]}
]);
// 设置活动输入框
const setActiveInput = (input) => {
activeInput.value = input;
};
// 处理日期选择
const handleDateSelect = (date) => {
if (activeInput.value === 'start') {
startDate.value = date;
// 如果结束日期在开始日期之前,清空结束日期
if (endDate.value && date.isAfter(endDate.value)) {
endDate.value = null;
}
activeInput.value = 'end';
} else {
// 验证日期范围
if (startDate.value && date.isBefore(startDate.value)) {
// 如果选择的结束日期在开始日期之前,交换它们
endDate.value = startDate.value;
startDate.value = date;
} else {
endDate.value = date;
}
// 检查最大范围限制
if (startDate.value && endDate.value) {
const daysDiff = endDate.value.diff(startDate.value, 'day');
if (daysDiff >= props.maxRange) {
endDate.value = startDate.value.add(props.maxRange - 1, 'day');
}
}
}
};
// 选择快捷范围
const selectQuickRange = (option) => {
const [start, end] = option.getRange();
startDate.value = start;
endDate.value = end;
};
// 确认选择
const confirmSelection = () => {
if (startDate.value && endDate.value) {
emit('update:modelValue', {
start: startDate.value.toDate(),
end: endDate.value.toDate()
});
emit('change', {
start: startDate.value.toDate(),
end: endDate.value.toDate()
});
}
isOpen.value = false;
};
// 清除选择
const clearSelection = () => {
startDate.value = null;
endDate.value = null;
emit('update:modelValue', { start: null, end: null });
emit('change', { start: null, end: null });
};
// 月份导航
const prevMonth = (baseMonth) => {
leftMonth.value = leftMonth.value.subtract(1, 'month');
rightMonth.value = rightMonth.value.subtract(1, 'month');
};
const nextMonth = (baseMonth) => {
leftMonth.value = leftMonth.value.add(1, 'month');
rightMonth.value = rightMonth.value.add(1, 'month');
};
const prevYear = (baseMonth) => {
leftMonth.value = leftMonth.value.subtract(1, 'year');
rightMonth.value = rightMonth.value.subtract(1, 'year');
};
const nextYear = (baseMonth) => {
leftMonth.value = leftMonth.value.add(1, 'year');
rightMonth.value = rightMonth.value.add(1, 'year');
};
// 监听值变化
watch(() => props.modelValue, (newValue) => {
if (newValue.start) {
startDate.value = dayjs(newValue.start);
leftMonth.value = startDate.value.startOf('month');
rightMonth.value = leftMonth.value.add(1, 'month');
}
if (newValue.end) {
endDate.value = dayjs(newValue.end);
}
}, { immediate: true });
return {
isOpen,
activeInput,
startDate,
endDate,
leftMonth,
rightMonth,
startDisplay,
endDisplay,
leftMonthText,
rightMonthText,
selectedDays,
quickOptions,
setActiveInput,
handleDateSelect,
selectQuickRange,
confirmSelection,
clearSelection,
prevMonth,
nextMonth,
prevYear,
nextYear
};
}
};
</script>
五、Vant DatePicker 集成
5.1 基础Vant日期选择器
<template>
<div class="vant-date-picker-demo">
<!-- 基础日期选择 -->
<div class="demo-section">
<h3>基础用法</h3>
<van-field
v-model="basicDate"
readonly
clickable
label="选择日期"
:placeholder="basicDate ? '' : '请选择日期'"
@click="showBasicPicker = true"
/>
<van-popup v-model:show="showBasicPicker" round position="bottom">
<van-date-picker
v-model="basicDate"
:min-date="minDate"
:max-date="maxDate"
@confirm="onBasicConfirm"
@cancel="showBasicPicker = false"
/>
</van-popup>
</div>
<!-- 日期时间选择 -->
<div class="demo-section">
<h3>日期时间选择</h3>
<van-field
v-model="datetime"
readonly
clickable
label="日期时间"
:placeholder="datetime ? '' : '请选择日期时间'"
@click="showDatetimePicker = true"
/>
<van-popup v-model:show="showDatetimePicker" round position="bottom">
<van-date-picker
v-model="datetime"
type="datetime"
title="选择日期时间"
:min-date="minDate"
:max-date="maxDate"
@confirm="onDatetimeConfirm"
@cancel="showDatetimePicker = false"
/>
</van-popup>
</div>
<!-- 日期范围选择 -->
<div class="demo-section">
<h3>日期范围选择</h3>
<van-field
v-model="dateRangeText"
readonly
clickable
label="日期范围"
placeholder="请选择日期范围"
@click="showRangePicker = true"
/>
<van-popup v-model:show="showRangePicker" round position="bottom">
<van-date-picker
v-model="dateRange"
type="daterange"
title="选择日期范围"
:min-date="minDate"
:max-date="maxDate"
@confirm="onRangeConfirm"
@cancel="showRangePicker = false"
/>
</van-popup>
</div>
<!-- 自定义列数据 -->
<div class="demo-section">
<h3>自定义列(年月日时分)</h3>
<van-field
v-model="customDateTime"
readonly
clickable
label="自定义格式"
placeholder="请选择"
@click="showCustomPicker = true"
/>
<van-popup v-model:show="showCustomPicker" round position="bottom">
<van-date-picker
v-model="customDateTime"
:columns-type="customColumns"
title="选择时间"
@confirm="onCustomConfirm"
@cancel="showCustomPicker = false"
/>
</van-popup>
</div>
<!-- 过滤选项 -->
<div class="demo-section">
<h3>过滤日期(禁用周末)</h3>
<van-field
v-model="filteredDate"
readonly
clickable
label="工作日选择"
placeholder="请选择工作日"
@click="showFilterPicker = true"
/>
<van-popup v-model:show="showFilterPicker" round position="bottom">
<van-date-picker
v-model="filteredDate"
:filter="dateFilter"
title="选择工作日"
@confirm="onFilterConfirm"
@cancel="showFilterPicker = false"
/>
</van-popup>
</div>
</div>
</template>
<script>
import { ref, computed } from 'vue';
import { showToast } from 'vant';
import dayjs from '@/utils/dayjs-config';
export default {
name: 'VantDatePickerDemo',
setup() {
// 基础日期选择
const basicDate = ref('');
const showBasicPicker = ref(false);
// 日期时间选择
const datetime = ref('');
const showDatetimePicker = ref(false);
// 日期范围选择
const dateRange = ref(['', '']);
const showRangePicker = ref(false);
// 自定义列
const customDateTime = ref('');
const showCustomPicker = ref(false);
// 过滤日期
const filteredDate = ref('');
const showFilterPicker = ref(false);
// 日期范围
const minDate = new Date(2020, 0, 1);
const maxDate = new Date(2025, 11, 31);
// 计算属性
const dateRangeText = computed(() => {
const [start, end] = dateRange.value;
if (!start || !end) return '';
return `${formatDate(start)} 至 ${formatDate(end)}`;
});
// 自定义列配置
const customColumns = ref(['year', 'month', 'day', 'hour', 'minute']);
// 日期过滤器 - 禁用周末
const dateFilter = (type, options) => {
if (type === 'weekday') {
return options.filter(option => {
// 0是周日,6是周六
const date = new Date(option);
return date.getDay() !== 0 && date.getDay() !== 6;
});
}
return options;
};
// 格式化日期
const formatDate = (date) => {
return dayjs(date).format('YYYY-MM-DD');
};
// 确认事件处理
const onBasicConfirm = (value) => {
showBasicPicker.value = false;
showToast(`选择了日期: ${formatDate(value)}`);
};
const onDatetimeConfirm = (value) => {
showDatetimePicker.value = false;
const formatted = dayjs(value).format('YYYY-MM-DD HH:mm');
showToast(`选择了时间: ${formatted}`);
};
const onRangeConfirm = (value) => {
showRangePicker.value = false;
const [start, end] = value;
showToast(`选择了范围: ${formatDate(start)} 至 ${formatDate(end)}`);
};
const onCustomConfirm = (value) => {
showCustomPicker.value = false;
showToast(`选择了: ${value}`);
};
const onFilterConfirm = (value) => {
showFilterPicker.value = false;
showToast(`选择了工作日: ${formatDate(value)}`);
};
return {
// 基础日期
basicDate,
showBasicPicker,
onBasicConfirm,
// 日期时间
datetime,
showDatetimePicker,
onDatetimeConfirm,
// 日期范围
dateRange,
dateRangeText,
showRangePicker,
onRangeConfirm,
// 自定义
customDateTime,
customColumns,
showCustomPicker,
onCustomConfirm,
// 过滤
filteredDate,
showFilterPicker,
dateFilter,
onFilterConfirm,
// 公共配置
minDate,
maxDate
};
}
};
</script>
<style scoped>
.vant-date-picker-demo {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.demo-section {
background: white;
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.demo-section h3 {
margin: 0 0 12px 0;
color: #333;
font-size: 16px;
}
/* 自定义样式增强 */
:deep(.van-picker) {
background: #fff;
}
:deep(.van-picker__toolbar) {
background: #f7f8fa;
}
:deep(.van-picker__confirm) {
color: #1989fa;
}
:deep(.van-picker__cancel) {
color: #969799;
}
:deep(.van-picker-column__item--selected) {
color: #323233;
font-weight: 500;
}
</style>
5.2 高级Vant日期选择功能
<template>
<div class="advanced-vant-picker">
<!-- 受控组件示例 -->
<div class="demo-section">
<h3>受控组件模式</h3>
<van-field
v-model="controlledDate"
readonly
clickable
label="受控日期"
:placeholder="controlledDate ? '' : '请选择日期'"
@click="showControlledPicker = true"
/>
<van-button
size="small"
type="primary"
@click="setToToday"
style="margin-left: 10px;"
>
设为今天
</van-button>
<van-button
size="small"
type="default"
@click="clearControlledDate"
style="margin-left: 5px;"
>
清除
</van-button>
<van-popup v-model:show="showControlledPicker" round position="bottom">
<van-date-picker
v-model="pickerDate"
:min-date="minDate"
:max-date="maxDate"
@confirm="onControlledConfirm"
@cancel="showControlledPicker = false"
@change="onPickerChange"
/>
</van-popup>
</div>
<!-- 动态配置 -->
<div class="demo-section">
<h3>动态配置</h3>
<div class="config-panel">
<van-radio-group v-model="pickerType" direction="horizontal">
<van-radio name="date">日期</van-radio>
<van-radio name="datetime">日期时间</van-radio>
<van-radio name="year-month">年月</van-radio>
</van-radio-group>
<van-checkbox v-model="showWeekday" style="margin-top: 10px;">
显示周几
</van-checkbox>
</div>
<van-field
v-model="dynamicDate"
readonly
clickable
label="动态选择器"
:placeholder="dynamicDate ? '' : '请选择'"
@click="showDynamicPicker = true"
/>
<van-popup v-model:show="showDynamicPicker" round position="bottom">
<van-date-picker
v-model="dynamicDate"
:type="pickerType"
:formatter="dynamicFormatter"
title="动态选择器"
@confirm="onDynamicConfirm"
@cancel="showDynamicPicker = false"
/>
</van-popup>
</div>
<!-- 自定义确认按钮 -->
<div class="demo-section">
<h3>自定义工具栏</h3>
<van-field
v-model="customToolbarDate"
readonly
clickable
label="自定义工具栏"
placeholder="请选择日期"
@click="showCustomToolbarPicker = true"
/>
<van-popup v-model:show="showCustomToolbarPicker" round position="bottom">
<div class="custom-toolbar">
<button class="toolbar-btn" @click="handleQuickSelect('today')">今天</button>
<button class="toolbar-btn" @click="handleQuickSelect('tomorrow')">明天</button>
<button class="toolbar-btn" @click="handleQuickSelect('weekend')">周末</button>
<span class="toolbar-title">选择日期</span>
<button class="toolbar-btn confirm" @click="confirmCustomSelection">确定</button>
<button class="toolbar-btn" @click="showCustomToolbarPicker = false">取消</button>
</div>
<van-date-picker
ref="customPickerRef"
v-model="customToolbarDate"
:min-date="minDate"
:max-date="maxDate"
show-toolbar="false"
/>
</van-popup>
</div>
<!-- 多列联动 -->
<div class="demo-section">
<h3>多列联动(省市区)</h3>
<van-field
v-model="areaText"
readonly
clickable
label="省市区"
placeholder="请选择省市区"
@click="showAreaPicker = true"
/>
<van-popup v-model:show="showAreaPicker" round position="bottom">
<van-picker
v-model="selectedArea"
title="省市区选择"
:columns="areaColumns"
@change="onAreaChange"
@confirm="onAreaConfirm"
@cancel="showAreaPicker = false"
/>
</van-popup>
</div>
</div>
</template>
<script>
import { ref, computed, watch } from 'vue';
import { showToast } from 'vant';
import dayjs from '@/utils/dayjs-config';
// 模拟省市区数据
const areaData = {
'浙江省': {
'杭州市': ['上城区', '下城区', '江干区', '拱墅区', '西湖区'],
'宁波市': ['海曙区', '江东区', '江北区', '北仑区', '镇海区'],
'温州市': ['鹿城区', '龙湾区', '瓯海区', '瑞安市', '乐清市']
},
'江苏省': {
'南京市': ['玄武区', '秦淮区', '建邺区', '鼓楼区', '浦口区'],
'苏州市': ['姑苏区', '虎丘区', '吴中区', '相城区', '吴江区'],
'无锡市': ['梁溪区', '锡山区', '惠山区', '滨湖区', '新吴区']
}
};
export default {
name: 'AdvancedVantPicker',
setup() {
// 受控组件相关
const controlledDate = ref('');
const pickerDate = ref('');
const showControlledPicker = ref(false);
// 动态配置相关
const pickerType = ref('date');
const showWeekday = ref(false);
const dynamicDate = ref('');
const showDynamicPicker = ref(false);
// 自定义工具栏相关
const customToolbarDate = ref('');
const showCustomToolbarPicker = ref(false);
const customPickerRef = ref(null);
// 省市区选择相关
const selectedArea = ref(['浙江省', '杭州市', '西湖区']);
const showAreaPicker = ref(false);
// 日期范围
const minDate = new Date(2020, 0, 1);
const maxDate = new Date(2025, 11, 31);
// 计算属性
const areaText = computed(() => selectedArea.value.join(' '));
const areaColumns = computed(() => [
{
values: Object.keys(areaData),
className: 'column1'
},
{
values: Object.keys(areaData[selectedArea.value[0]] || {}),
className: 'column2'
},
{
values: areaData[selectedArea.value[0]]?.[selectedArea.value[1]] || [],
className: 'column3'
}
]);
// 动态格式化器
const dynamicFormatter = (type, option) => {
if (showWeekday.value && type === 'day') {
const date = new Date(option);
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
const weekday = weekdays[date.getDay()];
return `${option} 周${weekday}`;
}
return option;
};
// 受控组件方法
const setToToday = () => {
controlledDate.value = new Date();
pickerDate.value = new Date();
showToast('已设置为今天');
};
const clearControlledDate = () => {
controlledDate.value = '';
pickerDate.value = '';
showToast('已清除选择');
};
const onControlledConfirm = (value) => {
controlledDate.value = value;
showControlledPicker.value = false;
showToast(`选择了: ${formatDate(value)}`);
};
const onPickerChange = (picker, values, indexes) => {
console.log('选择器变化:', values, indexes);
};
// 动态选择器方法
const onDynamicConfirm = (value) => {
dynamicDate.value = value;
showDynamicPicker.value = false;
showToast(`选择了: ${formatDynamicValue(value)}`);
};
// 自定义工具栏方法
const handleQuickSelect = (type) => {
let date = new Date();
switch (type) {
case 'today':
date = new Date();
break;
case 'tomorrow':
date = new Date(date.getTime() + 24 * 60 * 60 * 1000);
break;
case 'weekend':
// 找到下一个周六
const dayOfWeek = date.getDay();
const daysUntilSaturday = dayOfWeek === 6 ? 0 : 6 - dayOfWeek;
date = new Date(date.getTime() + daysUntilSaturday * 24 * 60 * 60 * 1000);
break;
}
if (customPickerRef.value) {
customPickerRef.value.setValues([date.getFullYear(), date.getMonth() + 1, date.getDate()]);
}
};
const confirmCustomSelection = () => {
showCustomToolbarPicker.value = false;
showToast(`选择了: ${formatDate(customToolbarDate.value)}`);
};
// 省市区选择方法
const onAreaChange = (picker, values, indexes) => {
// 联动更新第二列和第三列
picker.setColumnValues(1, Object.keys(areaData[values[0]] || {}));
picker.setColumnValues(2, areaData[values[0]]?.[values[1]] || []);
};
const onAreaConfirm = (values) => {
selectedArea.value = values;
showAreaPicker.value = false;
showToast(`选择了: ${values.join(' ')}`);
};
// 工具函数
const formatDate = (date) => {
if (!date) return '';
return dayjs(date).format('YYYY-MM-DD');
};
const formatDynamicValue = (value) => {
if (!value) return '';
if (pickerType.value === 'year-month') {
return dayjs(value).format('YYYY年MM月');
} else if (pickerType.value === 'datetime') {
return dayjs(value).format('YYYY-MM-DD HH:mm');
} else {
return formatDate(value);
}
};
// 监听器
watch(pickerType, () => {
dynamicDate.value = '';
});
watch(showControlledPicker, (newVal) => {
if (newVal) {
// 显示选择器时同步数据
pickerDate.value = controlledDate.value || new Date();
}
});
return {
// 受控组件
controlledDate,
pickerDate,
showControlledPicker,
setToToday,
clearControlledDate,
onControlledConfirm,
onPickerChange,
// 动态配置
pickerType,
showWeekday,
dynamicDate,
showDynamicPicker,
dynamicFormatter,
onDynamicConfirm,
// 自定义工具栏
customToolbarDate,
showCustomToolbarPicker,
customPickerRef,
handleQuickSelect,
confirmCustomSelection,
// 省市区选择
selectedArea,
areaText,
showAreaPicker,
areaColumns,
onAreaChange,
onAreaConfirm,
// 公共配置
minDate,
maxDate
};
}
};
</script>
<style scoped>
.advanced-vant-picker {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.demo-section {
background: white;
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.demo-section h3 {
margin: 0 0 12px 0;
color: #333;
font-size: 16px;
}
.config-panel {
margin-bottom: 12px;
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
}
.custom-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #f7f8fa;
border-bottom: 1px solid #ebedf0;
}
.toolbar-title {
font-weight: 500;
color: #323233;
}
.toolbar-btn {
padding: 6px 12px;
border: none;
background: transparent;
color: #1989fa;
cursor: pointer;
border-radius: 4px;
font-size: 14px;
}
.toolbar-btn:hover {
background: #f0f0f0;
}
.toolbar-btn.confirm {
font-weight: 500;
}
/* 自定义列样式 */
:deep(.column1) {
color: #1989fa;
}
:deep(.column2) {
color: #07c160;
}
:deep(.column3) {
color: #ff976a;
}
</style>
六、实际应用场景
6.1 预约系统日期选择
<template>
<div class="appointment-system">
<div class="header">
<h2>预约服务系统</h2>
<p>请选择预约日期和时间</p>
</div>
<div class="appointment-content">
<!-- 服务选择 -->
<div class="service-section">
<h3>选择服务</h3>
<van-radio-group v-model="selectedService" direction="horizontal">
<van-radio
v-for="service in services"
:key="service.id"
:name="service.id"
>
{{ service.name }}
</van-radio>
</van-radio-group>
</div>
<!-- 日期选择 -->
<div class="date-section">
<h3>选择日期</h3>
<div class="date-display">
<div
v-for="date in visibleDates"
:key="date.key"
class="date-item"
:class="getDateItemClass(date)"
@click="selectDate(date)"
>
<div class="date-weekday">{{ date.weekday }}</div>
<div class="date-day">{{ date.day }}</div>
<div class="date-month">{{ date.month }}月</div>
<div v-if="date.slots > 0" class="slot-info">
{{ date.slots }}个空位
</div>
<div v-else-if="date.slots === 0" class="slot-full">
已满
</div>
</div>
</div>
<!-- 周导航 -->
<div class="week-navigation">
<button @click="prevWeek" class="nav-btn">‹ 上一周</button>
<span class="current-week">{{ currentWeekRange }}</span>
<button @click="nextWeek" class="nav-btn">下一周 ›</button>
</div>
</div>
<!-- 时间选择 -->
<div v-if="selectedDate" class="time-section">
<h3>选择时间</h3>
<div class="time-slots">
<div
v-for="slot in availableTimeSlots"
:key="slot.time"
class="time-slot"
:class="{ selected: selectedTime === slot.time, disabled: !slot.available }"
@click="selectTime(slot)"
>
<span class="slot-time">{{ slot.displayTime }}</span>
<span class="slot-status">{{ slot.available ? '可预约' : '已满' }}</span>
</div>
</div>
</div>
<!-- 预约信息汇总 -->
<div v-if="selectedDate && selectedTime" class="appointment-summary">
<h3>预约信息</h3>
<div class="summary-content">
<div class="summary-item">
<span class="label">服务项目:</span>
<span class="value">{{ selectedServiceName }}</span>
</div>
<div class="summary-item">
<span class="label">预约时间:</span>
<span class="value">{{ appointmentDateTime }}</span>
</div>
<div class="summary-item">
<span class="label">预计时长:</span>
<span class="value">{{ estimatedDuration }}分钟</span>
</div>
</div>
<van-button
type="primary"
size="large"
@click="confirmAppointment"
class="confirm-btn"
>
确认预约
</van-button>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
import { showToast, showDialog } from 'vant';
import dayjs from '@/utils/dayjs-config';
export default {
name: 'AppointmentSystem',
setup() {
// 服务数据
const services = ref([
{ id: 1, name: '基础清洁', duration: 60, price: 100 },
{ id: 2, name: '深度清洁', duration: 120, price: 200 },
{ id: 3, name: '消毒服务', duration: 90, price: 150 },
{ id: 4, name: '保养服务', duration: 180, price: 300 }
]);
const selectedService = ref(1);
// 日期相关
const currentWeekStart = ref(dayjs().startOf('week'));
const selectedDate = ref(null);
const selectedTime = ref('');
// 模拟可预约时间段数据
const timeSlots = ref([
'09:00', '10:00', '11:00', '14:00', '15:00', '16:00', '17:00'
]);
// 计算属性
const selectedServiceName = computed(() => {
const service = services.value.find(s => s.id === selectedService.value);
return service ? service.name : '';
});
const estimatedDuration = computed(() => {
const service = services.value.find(s => s.id === selectedService.value);
return service ? service.duration : 0;
});
const visibleDates = computed(() => {
const dates = [];
for (let i = 0; i < 7; i++) {
const date = currentWeekStart.value.add(i, 'day');
const slots = Math.floor(Math.random() * 5); // 模拟空位数据
dates.push({
date: date,
key: date.format('YYYY-MM-DD'),
day: date.date(),
month: date.month() + 1,
weekday: getWeekdayChinese(date.day()),
slots: slots,
isToday: date.isToday(),
isSelected: selectedDate.value ? date.isSame(selectedDate.value, 'day') : false
});
}
return dates;
});
const currentWeekRange = computed(() => {
const start = currentWeekStart.value;
const end = start.add(6, 'day');
return `${start.format('MM月DD日')} - ${end.format('MM月DD日')}`;
});
const availableTimeSlots = computed(() => {
if (!selectedDate.value) return [];
return timeSlots.value.map(time => {
// 模拟时间段可用性
const available = Math.random() > 0.3; // 70%的可用概率
return {
time: time,
displayTime: time,
available: available,
datetime: `${selectedDate.value.format('YYYY-MM-DD')} ${time}`
};
});
});
const appointmentDateTime = computed(() => {
if (!selectedDate.value || !selectedTime.value) return '';
return `${selectedDate.value.format('YYYY年MM月DD日')} ${selectedTime.value}`;
});
// 方法
const getWeekdayChinese = (day) => {
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
return `周${weekdays[day]}`;
};
const getDateItemClass = (date) => {
const classes = [];
if (date.isToday) classes.push('today');
if (date.isSelected) classes.push('selected');
if (date.slots === 0) classes.push('full');
if (date.date.isBefore(dayjs(), 'day')) classes.push('past');
return classes;
};
const selectDate = (date) => {
if (date.date.isBefore(dayjs(), 'day')) {
showToast('不能选择过去的日期');
return;
}
if (date.slots === 0) {
showToast('该日期已无空位');
return;
}
selectedDate.value = date.date;
selectedTime.value = ''; // 重置时间选择
};
const selectTime = (slot) => {
if (!slot.available) {
showToast('该时间段不可用');
return;
}
selectedTime.value = slot.time;
};
const prevWeek = () => {
currentWeekStart.value = currentWeekStart.value.subtract(7, 'day');
};
const nextWeek = () => {
currentWeekStart.value = currentWeekStart.value.add(7, 'day');
};
const confirmAppointment = async () => {
try {
await showDialog({
title: '确认预约',
message: `确认预约${selectedServiceName.value}服务,时间:${appointmentDateTime.value}?`,
showCancelButton: true
});
// 模拟API调用
const appointmentData = {
serviceId: selectedService.value,
datetime: `${selectedDate.value.format('YYYY-MM-DD')} ${selectedTime.value}:00`,
duration: estimatedDuration.value
};
// 这里调用实际的预约API
console.log('预约数据:', appointmentData);
showToast('预约成功!');
// 重置选择
selectedDate.value = null;
selectedTime.value = '';
} catch (error) {
console.error('预约失败:', error);
}
};
onMounted(() => {
// 初始化选择今天(如果今天有空位)
const today = visibleDates.value.find(date => date.isToday);
if (today && today.slots > 0) {
selectedDate.value = today.date;
}
});
return {
services,
selectedService,
selectedDate,
selectedTime,
visibleDates,
currentWeekRange,
availableTimeSlots,
selectedServiceName,
estimatedDuration,
appointmentDateTime,
getDateItemClass,
selectDate,
selectTime,
prevWeek,
nextWeek,
confirmAppointment
};
}
};
</script>
<style scoped>
.appointment-system {
max-width: 400px;
margin: 0 auto;
padding: 20px;
background: white;
min-height: 100vh;
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
}
.header h2 {
margin: 0 0 8px 0;
color: #333;
}
.header p {
margin: 0;
color: #666;
font-size: 14px;
}
.service-section,
.date-section,
.time-section {
margin-bottom: 30px;
}
.service-section h3,
.date-section h3,
.time-section h3 {
margin: 0 0 16px 0;
color: #333;
font-size: 16px;
}
.date-display {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.date-item {
padding: 12px 4px;
text-align: center;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
}
.date-item:hover {
background: #f5f5f5;
}
.date-item.today {
border-color: #1989fa;
background: #f0f8ff;
}
.date-item.selected {
border-color: #07c160;
background: #f0f9f4;
}
.date-item.past {
opacity: 0.5;
cursor: not-allowed;
}
.date-item.full {
opacity: 0.5;
cursor: not-allowed;
}
.date-weekday {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.date-day {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 2px;
}
.date-month {
font-size: 10px;
color: #999;
}
.slot-info {
font-size: 10px;
color: #07c160;
margin-top: 2px;
}
.slot-full {
font-size: 10px;
color: #ee0a24;
margin-top: 2px;
}
.week-navigation {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 8px;
}
.nav-btn {
border: none;
background: none;
color: #1989fa;
cursor: pointer;
font-size: 14px;
padding: 8px;
}
.nav-btn:disabled {
color: #ccc;
cursor: not-allowed;
}
.current-week {
font-size: 14px;
color: #666;
font-weight: 500;
}
.time-slots {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.time-slot {
padding: 12px 8px;
text-align: center;
border: 1px solid #e8e8e8;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
}
.time-slot:hover {
border-color: #1989fa;
}
.time-slot.selected {
border-color: #07c160;
background: #f0f9f4;
color: #07c160;
}
.time-slot.disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f5f5f5;
}
.slot-time {
display: block;
font-size: 14px;
font-weight: 500;
}
.slot-status {
display: block;
font-size: 12px;
margin-top: 2px;
}
.appointment-summary {
background: #f8f9fa;
padding: 16px;
border-radius: 8px;
margin-top: 20px;
}
.appointment-summary h3 {
margin: 0 0 12px 0;
color: #333;
}
.summary-content {
margin-bottom: 16px;
}
.summary-item {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
}
.summary-item .label {
color: #666;
}
.summary-item .value {
color: #333;
font-weight: 500;
}
.confirm-btn {
width: 100%;
}
</style>
七、测试与质量保证
7.1 组件单元测试
// tests/unit/DatePicker.spec.js
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
describe('日期选择器组件测试', () => {
describe('CustomDatePicker 组件', () => {
it('应该正确初始化并显示当前日期', async () => {
const wrapper = mount(CustomDatePicker, {
props: {
modelValue: '2024-01-15'
}
});
await nextTick();
expect(wrapper.find('.input-field').element.value).toBe('2024-01-15');
expect(wrapper.find('.current-month').text()).toBe('2024年01月');
});
it('应该响应日期选择', async () => {
const wrapper = mount(CustomDatePicker);
// 打开选择器
await wrapper.find('.date-input').trigger('click');
await nextTick();
// 选择日期
const dateCells = wrapper.findAll('.date-cell:not(.disabled)');
await dateCells[10].trigger('click'); // 选择第10个可用日期
// 确认选择
await wrapper.find('.primary').trigger('click');
expect(wrapper.emitted('update:modelValue')).toBeTruthy();
expect(wrapper.emitted('change')).toBeTruthy();
});
it('应该验证日期范围', async () => {
const wrapper = mount(CustomDatePicker, {
props: {
minDate: '2024-01-01',
maxDate: '2024-01-31'
}
});
await wrapper.find('.date-input').trigger('click');
await nextTick();
// 检查禁用日期
const disabledCells = wrapper.findAll('.date-cell.disabled');
expect(disabledCells.length).toBeGreaterThan(0);
});
});
describe('Vant DatePicker 集成', () => {
it('应该正确显示Vant日期选择器', async () => {
const wrapper = mount(VantDatePickerDemo);
const field = wrapper.find('.van-field');
await field.trigger('click');
expect(wrapper.find('.van-popup').isVisible()).toBe(true);
expect(wrapper.find('.van-date-picker').exists()).toBe(true);
});
it('应该处理日期时间选择', async () => {
const wrapper = mount(VantDatePickerDemo);
// 打开日期时间选择器
await wrapper.findAll('.van-field')[1].trigger('click');
await nextTick();
// 模拟选择
const picker = wrapper.findComponent({ name: 'VanDatePicker' });
await picker.vm.$emit('confirm', new Date('2024-01-15 14:30:00'));
expect(wrapper.vm.datetime).toBe('2024-01-15 14:30:00');
});
});
describe('日期工具函数测试', () => {
it('应该正确计算工作日', () => {
const start = '2024-01-01';
const end = '2024-01-07'; // 包含周末
const holidays = ['2024-01-01']; // 元旦
const workdays = DateUtils.calculateWorkdays(start, end, holidays);
expect(workdays).toBe(4); // 扣除周末和节假日
});
it
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)