Skip to content

C_Date 日期选择器组件

📅 基于 Naive UI 的智能日期选择器,让日期选择更智能、更便捷

🚀 特性

  • 🔄 五种选择模式: 单日期、日期时间、日期范围、日期时间范围、智能范围
  • 🧠 智能联动限制: 结束日期自动限制不早于开始日期
  • 🚫 灵活禁用规则: 支持禁用今天之前/之后的日期
  • ⚙️ 高度可配置: 支持占位符、格式化、额外属性传递
  • 💪 TypeScript: 完整的类型定义和类型安全
  • 📡 丰富事件系统: 多种日期变化事件回调
  • 🔧 暴露接口: 提供清空方法和内部状态访问
  • ⚡ 响应式设计: 自适应布局和样式

📦 安装

bash
# 基于 Naive UI,确保已安装依赖
npm install naive-ui

🎯 快速开始

基础使用

vue
<template>
  <!-- 最简单的单日期选择 -->
  <C_Date mode="date" @change="handleDateChange" />

  <!-- 日期范围选择 -->
  <C_Date mode="daterange" @change="handleRangeChange" />

  <!-- 智能双日期选择 -->
  <C_Date mode="smart-range" @update:smartRange="handleSmartRangeChange" />
</template>

<script setup>
const handleDateChange = (value) => {
  console.log('日期变更:', value)
}

const handleRangeChange = (value) => {
  console.log('范围变更:', value)
}

const handleSmartRangeChange = (value) => {
  console.log('智能范围变更:', value)
}
</script>

五种选择模式

vue
<template>
  <div class="date-modes-demo">
    <!-- 1. 单日期选择 -->
    <div class="demo-section">
      <h4>单日期选择</h4>
      <C_Date
        mode="date"
        placeholder="请选择日期"
        @update:singleDate="handleSingleDate"
      />
    </div>

    <!-- 2. 日期时间选择 -->
    <div class="demo-section">
      <h4>日期时间选择</h4>
      <C_Date
        mode="datetime"
        placeholder="请选择日期时间"
        value-format="yyyy-MM-dd HH:mm:ss"
        @update:singleDateTime="handleSingleDateTime"
      />
    </div>

    <!-- 3. 日期范围选择 -->
    <div class="demo-section">
      <h4>日期范围选择</h4>
      <C_Date
        mode="daterange"
        start-placeholder="开始日期"
        end-placeholder="结束日期"
        @update:dateRange="handleDateRange"
      />
    </div>

    <!-- 4. 日期时间范围选择 -->
    <div class="demo-section">
      <h4>日期时间范围选择</h4>
      <C_Date
        mode="datetimerange"
        start-placeholder="开始日期时间"
        end-placeholder="结束日期时间"
        value-format="yyyy-MM-dd HH:mm:ss"
        @update:dateTimeRange="handleDateTimeRange"
      />
    </div>

    <!-- 5. 智能双日期选择 -->
    <div class="demo-section">
      <h4>智能双日期选择</h4>
      <C_Date
        mode="smart-range"
        start-placeholder="选择开始日期"
        end-placeholder="选择结束日期"
        @update:smartRange="handleSmartRange"
      />
    </div>
  </div>
</template>

<script setup>
const handleSingleDate = (value) => {
  console.log('单日期:', value)
}

const handleSingleDateTime = (value) => {
  console.log('日期时间:', value)
}

const handleDateRange = (value) => {
  console.log('日期范围:', value)
}

const handleDateTimeRange = (value) => {
  console.log('日期时间范围:', value)
}

const handleSmartRange = (value) => {
  console.log('智能范围:', value)
}
</script>

<style scoped>
.demo-section {
  margin-bottom: 20px;
  padding: 16px;
  border: 1px solid #e8e8e8;
  border-radius: 8px;
}
</style>

📖 API 文档

Props

参数类型默认值说明
mode'date' | 'datetime' | 'daterange' | 'datetimerange' | 'smart-range''date'日期选择器模式
placeholderstring''占位符文本(单日期模式)
startPlaceholderstring''开始日期占位符(范围模式)
endPlaceholderstring''结束日期占位符(范围模式)
disabledbooleanfalse是否禁用
disabledBeforeTodaybooleanfalse是否禁用今天之前的日期
disabledAfterTodaybooleanfalse是否禁用今天之后的日期
valueFormatstring'yyyy-MM-dd'值格式化字符串
startDatePropsRecord<string, any>{}开始日期额外属性(智能范围)
endDatePropsRecord<string, any>{}结束日期额外属性(智能范围)

Events

事件名参数说明
update:singleDate(value: number | null)单日期变化事件
update:singleDateTime(value: number | null)单日期时间变化事件
update:dateRange(value: [number, number] | null)日期范围变化事件
update:dateTimeRange(value: [number, number] | null)日期时间范围变化事件
update:smartRange(value: [number, number] | null)智能范围变化事件
change(value: number | [number, number] | null)通用变化事件

暴露方法

方法名参数返回值说明
clearAll-void清空所有日期值

暴露属性

属性名类型说明
singleDateRef<number | null>单日期值
singleDateTimeRef<number | null>单日期时间值
dateRangeRef<[number, number] | null>日期范围值
dateTimeRangeRef<[number, number] | null>日期时间范围值
startDateRef<number | null>智能范围开始日期
endDateRef<number | null>智能范围结束日期

类型定义

日期选择器模式

typescript
type DatePickerMode =
  | 'date' // 单日期选择
  | 'datetime' // 日期时间选择
  | 'daterange' // 日期范围选择
  | 'datetimerange' // 日期时间范围选择
  | 'smart-range' // 智能双日期选择

日期值类型

typescript
type DateValue = number | null
type DateRangeValue = [number, number] | null

🎨 使用示例

场景 1: 假期申请系统

vue
<template>
  <div class="vacation-application">
    <h3>假期申请</h3>
    <n-form :model="vacationForm" :rules="vacationRules" ref="formRef">
      <n-form-item label="假期类型" path="type">
        <n-select
          v-model:value="vacationForm.type"
          :options="vacationTypes"
          placeholder="请选择假期类型"
        />
      </n-form-item>

      <n-form-item label="假期时间" path="dateRange">
        <C_Date
          mode="smart-range"
          start-placeholder="假期开始日期"
          end-placeholder="假期结束日期"
          :disabled-before-today="true"
          value-format="yyyy-MM-dd"
          @update:smartRange="handleVacationDateChange"
        />
      </n-form-item>

      <n-form-item label="申请原因" path="reason">
        <n-input
          v-model:value="vacationForm.reason"
          type="textarea"
          placeholder="请填写申请原因"
          :rows="3"
        />
      </n-form-item>

      <n-form-item>
        <n-button type="primary" @click="handleSubmit">提交申请</n-button>
        <n-button @click="handleReset">重置</n-button>
      </n-form-item>
    </n-form>

    <!-- 申请结果 -->
    <div v-if="applicationResult" class="application-result">
      <n-alert type="success" title="申请提交成功">
        <p>假期类型: {{ applicationResult.typeName }}</p>
        <p>假期时间: {{ applicationResult.dateRange }}</p>
        <p>假期天数: {{ applicationResult.days }} 天</p>
      </n-alert>
    </div>
  </div>
</template>

<script setup>
const formRef = ref()
const vacationForm = ref({
  type: null,
  dateRange: null,
  reason: '',
})

const applicationResult = ref(null)

const vacationTypes = [
  { label: '年假', value: 'annual' },
  { label: '病假', value: 'sick' },
  { label: '事假', value: 'personal' },
  { label: '调休', value: 'compensatory' },
]

const vacationRules = {
  type: {
    required: true,
    message: '请选择假期类型',
    trigger: 'change',
  },
  dateRange: {
    validator: (rule, value) => {
      if (!value || value.length !== 2) {
        return new Error('请选择完整的假期时间')
      }

      const [start, end] = value
      const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24))

      if (daysDiff > 30) {
        return new Error('单次假期申请不能超过30天')
      }

      return true
    },
    trigger: 'change',
  },
  reason: {
    required: true,
    message: '请填写申请原因',
    trigger: 'blur',
  },
}

const handleVacationDateChange = (value) => {
  vacationForm.value.dateRange = value
}

const handleSubmit = () => {
  formRef.value?.validate((errors) => {
    if (!errors) {
      const { type, dateRange, reason } = vacationForm.value
      const typeName = vacationTypes.find((t) => t.value === type)?.label
      const [start, end] = dateRange
      const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1

      applicationResult.value = {
        typeName,
        dateRange: `${formatDate(start)} 至 ${formatDate(end)}`,
        days,
      }
    }
  })
}

const handleReset = () => {
  vacationForm.value = {
    type: null,
    dateRange: null,
    reason: '',
  }
  applicationResult.value = null
}

const formatDate = (timestamp) => {
  return new Date(timestamp).toLocaleDateString('zh-CN')
}
</script>

<style scoped>
.vacation-application {
  max-width: 600px;
  margin: 0 auto;
  padding: 24px;
}

.application-result {
  margin-top: 24px;
}
</style>

场景 2: 活动报名时间管理

vue
<template>
  <div class="event-registration">
    <h3>活动报名时间设置</h3>
    <n-card title="活动基本信息">
      <n-form :model="eventForm" label-placement="left" label-width="120px">
        <n-form-item label="活动名称">
          <n-input
            v-model:value="eventForm.name"
            placeholder="请输入活动名称"
          />
        </n-form-item>

        <n-form-item label="活动日期">
          <C_Date
            mode="daterange"
            start-placeholder="活动开始日期"
            end-placeholder="活动结束日期"
            :disabled-before-today="true"
            @update:dateRange="handleEventDateChange"
          />
        </n-form-item>

        <n-form-item label="报名时间">
          <C_Date
            mode="smart-range"
            start-placeholder="报名开始时间"
            end-placeholder="报名截止时间"
            :disabled-before-today="true"
            :end-date-props="registrationEndDateProps"
            @update:smartRange="handleRegistrationDateChange"
          />
        </n-form-item>

        <n-form-item label="活动地点">
          <n-input
            v-model:value="eventForm.location"
            placeholder="请输入活动地点"
          />
        </n-form-item>

        <n-form-item label="活动描述">
          <n-input
            v-model:value="eventForm.description"
            type="textarea"
            placeholder="请输入活动描述"
            :rows="3"
          />
        </n-form-item>

        <n-form-item>
          <n-button type="primary" @click="handleSaveEvent">保存活动</n-button>
          <n-button @click="handlePreviewEvent">预览</n-button>
        </n-form-item>
      </n-form>
    </n-card>

    <!-- 活动预览 -->
    <n-card v-if="eventPreview" title="活动预览" class="mt-4">
      <div class="event-preview">
        <h4>{{ eventPreview.name }}</h4>
        <div class="event-info">
          <p><strong>活动时间:</strong> {{ eventPreview.eventDate }}</p>
          <p><strong>报名时间:</strong> {{ eventPreview.registrationDate }}</p>
          <p><strong>活动地点:</strong> {{ eventPreview.location }}</p>
          <p><strong>活动描述:</strong> {{ eventPreview.description }}</p>
          <n-tag :type="eventPreview.registrationStatus.type">
            {{ eventPreview.registrationStatus.text }}
          </n-tag>
        </div>
      </div>
    </n-card>
  </div>
</template>

<script setup>
const eventForm = ref({
  name: '',
  eventDate: null,
  registrationDate: null,
  location: '',
  description: '',
})

const eventPreview = ref(null)

// 报名截止时间不能晚于活动开始时间
const registrationEndDateProps = computed(() => ({
  isDateDisabled: (timestamp) => {
    if (!eventForm.value.eventDate || !eventForm.value.eventDate[0]) {
      return false
    }

    const eventStartDate = eventForm.value.eventDate[0]
    return timestamp >= eventStartDate
  },
}))

const handleEventDateChange = (value) => {
  eventForm.value.eventDate = value
  // 如果活动日期改变,重置报名时间
  eventForm.value.registrationDate = null
}

const handleRegistrationDateChange = (value) => {
  eventForm.value.registrationDate = value
}

const handleSaveEvent = () => {
  if (validateEventForm()) {
    // 保存逻辑
    $message.success('活动保存成功')
    handlePreviewEvent()
  }
}

const handlePreviewEvent = () => {
  if (!validateEventForm()) return

  const { name, eventDate, registrationDate, location, description } =
    eventForm.value

  eventPreview.value = {
    name,
    eventDate: `${formatDate(eventDate[0])} 至 ${formatDate(eventDate[1])}`,
    registrationDate: `${formatDate(registrationDate[0])} 至 ${formatDate(
      registrationDate[1]
    )}`,
    location,
    description,
    registrationStatus: getRegistrationStatus(registrationDate),
  }
}

const validateEventForm = () => {
  const { name, eventDate, registrationDate, location } = eventForm.value

  if (!name || !eventDate || !registrationDate || !location) {
    $message.warning('请填写完整的活动信息')
    return false
  }

  return true
}

const getRegistrationStatus = (registrationDate) => {
  if (!registrationDate) return { type: 'default', text: '未设置' }

  const now = Date.now()
  const [start, end] = registrationDate

  if (now < start) {
    return { type: 'info', text: '报名未开始' }
  } else if (now >= start && now <= end) {
    return { type: 'success', text: '报名进行中' }
  } else {
    return { type: 'error', text: '报名已截止' }
  }
}

const formatDate = (timestamp) => {
  return new Date(timestamp).toLocaleDateString('zh-CN')
}
</script>

<style scoped>
.event-registration {
  max-width: 800px;
  margin: 0 auto;
  padding: 24px;
}

.event-preview {
  padding: 16px;
}

.event-info p {
  margin: 8px 0;
}
</style>

场景 3: 财务报表查询

vue
<template>
  <div class="financial-report">
    <h3>财务报表查询</h3>
    <n-card>
      <div class="query-form">
        <div class="form-row">
          <n-form-item label="报表类型">
            <n-select
              v-model:value="queryForm.reportType"
              :options="reportTypes"
              placeholder="请选择报表类型"
              @update:value="handleReportTypeChange"
            />
          </n-form-item>

          <n-form-item label="查询时间">
            <C_Date
              :mode="datePickerMode"
              :start-placeholder="startPlaceholder"
              :end-placeholder="endPlaceholder"
              :placeholder="singlePlaceholder"
              :disabled-after-today="true"
              value-format="yyyy-MM-dd"
              @change="handleDateChange"
            />
          </n-form-item>

          <n-form-item>
            <n-button type="primary" @click="handleQuery" :loading="querying">
              查询报表
            </n-button>
            <n-button @click="handleExport" :disabled="!reportData">
              导出报表
            </n-button>
          </n-form-item>
        </div>

        <!-- 快捷日期选择 -->
        <div class="quick-dates">
          <span class="quick-label">快捷选择:</span>
          <n-button
            v-for="quick in quickDates"
            :key="quick.label"
            size="small"
            @click="handleQuickDate(quick)"
          >
            {{ quick.label }}
          </n-button>
        </div>
      </div>

      <!-- 报表结果 -->
      <div v-if="reportData" class="report-result">
        <n-divider title-placement="left">
          {{ queryForm.reportTypeName }} - {{ reportPeriod }}
        </n-divider>

        <div class="report-summary">
          <n-statistic
            v-for="item in reportData.summary"
            :key="item.label"
            :label="item.label"
            :value="item.value"
            :precision="2"
          />
        </div>

        <n-data-table
          :columns="reportColumns"
          :data="reportData.details"
          :pagination="pagination"
        />
      </div>
    </n-card>
  </div>
</template>

<script setup>
const queryForm = ref({
  reportType: 'daily',
  reportTypeName: '日报表',
  dateValue: null,
})

const reportData = ref(null)
const querying = ref(false)

const reportTypes = [
  { label: '日报表', value: 'daily' },
  { label: '周报表', value: 'weekly' },
  { label: '月报表', value: 'monthly' },
  { label: '季度报表', value: 'quarterly' },
  { label: '年度报表', value: 'yearly' },
  { label: '自定义区间', value: 'custom' },
]

const quickDates = computed(() => {
  const today = new Date()
  const getDateTimestamp = (date) => {
    date.setHours(0, 0, 0, 0)
    return date.getTime()
  }

  return [
    {
      label: '今天',
      value: getDateTimestamp(new Date(today)),
      mode: 'date',
    },
    {
      label: '昨天',
      value: getDateTimestamp(new Date(today.getTime() - 24 * 60 * 60 * 1000)),
      mode: 'date',
    },
    {
      label: '本周',
      value: [
        getDateTimestamp(
          new Date(today.getTime() - today.getDay() * 24 * 60 * 60 * 1000)
        ),
        getDateTimestamp(new Date(today)),
      ],
      mode: 'range',
    },
    {
      label: '本月',
      value: [
        getDateTimestamp(new Date(today.getFullYear(), today.getMonth(), 1)),
        getDateTimestamp(new Date(today)),
      ],
      mode: 'range',
    },
    {
      label: '本季度',
      value: [
        getDateTimestamp(
          new Date(today.getFullYear(), Math.floor(today.getMonth() / 3) * 3, 1)
        ),
        getDateTimestamp(new Date(today)),
      ],
      mode: 'range',
    },
  ]
})

// 根据报表类型确定日期选择器模式
const datePickerMode = computed(() => {
  const { reportType } = queryForm.value
  if (reportType === 'daily') return 'date'
  if (reportType === 'custom') return 'daterange'
  return 'daterange'
})

const startPlaceholder = computed(() => {
  const { reportType } = queryForm.value
  const placeholders = {
    weekly: '选择周开始日期',
    monthly: '选择月份开始',
    quarterly: '选择季度开始',
    yearly: '选择年份开始',
    custom: '自定义开始日期',
  }
  return placeholders[reportType] || '开始日期'
})

const endPlaceholder = computed(() => {
  const { reportType } = queryForm.value
  const placeholders = {
    weekly: '选择周结束日期',
    monthly: '选择月份结束',
    quarterly: '选择季度结束',
    yearly: '选择年份结束',
    custom: '自定义结束日期',
  }
  return placeholders[reportType] || '结束日期'
})

const singlePlaceholder = computed(() => {
  return '请选择日期'
})

const reportPeriod = computed(() => {
  if (!queryForm.value.dateValue) return ''

  if (Array.isArray(queryForm.value.dateValue)) {
    const [start, end] = queryForm.value.dateValue
    return `${formatDate(start)} 至 ${formatDate(end)}`
  } else {
    return formatDate(queryForm.value.dateValue)
  }
})

const reportColumns = [
  { title: '日期', key: 'date' },
  {
    title: '收入',
    key: 'income',
    render: (row) => `¥${row.income.toFixed(2)}`,
  },
  {
    title: '支出',
    key: 'expense',
    render: (row) => `¥${row.expense.toFixed(2)}`,
  },
  {
    title: '净利润',
    key: 'profit',
    render: (row) => `¥${row.profit.toFixed(2)}`,
  },
]

const pagination = {
  pageSize: 10,
}

const handleReportTypeChange = (value) => {
  const reportType = reportTypes.find((t) => t.value === value)
  queryForm.value.reportTypeName = reportType?.label || ''
  queryForm.value.dateValue = null
}

const handleDateChange = (value) => {
  queryForm.value.dateValue = value
}

const handleQuickDate = (quick) => {
  queryForm.value.dateValue = quick.value
  // 如果需要,可以自动触发查询
  handleQuery()
}

const handleQuery = async () => {
  if (!queryForm.value.dateValue) {
    $message.warning('请选择查询时间')
    return
  }

  querying.value = true

  try {
    // 模拟API调用
    await new Promise((resolve) => setTimeout(resolve, 1000))

    // 模拟报表数据
    reportData.value = {
      summary: [
        { label: '总收入', value: 125680.5 },
        { label: '总支出', value: 89420.3 },
        { label: '净利润', value: 36260.2 },
        { label: '利润率', value: 28.86 },
      ],
      details: generateMockReportData(),
    }

    $message.success('报表查询成功')
  } catch (error) {
    $message.error('报表查询失败')
  } finally {
    querying.value = false
  }
}

const handleExport = () => {
  // 导出逻辑
  $message.success('报表导出成功')
}

const generateMockReportData = () => {
  // 生成模拟报表数据
  const data = []
  for (let i = 0; i < 15; i++) {
    const income = Math.random() * 10000 + 5000
    const expense = Math.random() * 8000 + 3000
    data.push({
      date: formatDate(Date.now() - i * 24 * 60 * 60 * 1000),
      income,
      expense,
      profit: income - expense,
    })
  }
  return data.reverse()
}

const formatDate = (timestamp) => {
  return new Date(timestamp).toLocaleDateString('zh-CN')
}
</script>

<style scoped>
.financial-report {
  max-width: 1200px;
  margin: 0 auto;
  padding: 24px;
}

.query-form {
  margin-bottom: 24px;
}

.form-row {
  display: flex;
  gap: 16px;
  align-items: end;
  margin-bottom: 16px;
}

.form-row .n-form-item {
  margin-bottom: 0;
}

.quick-dates {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px;
  background: #fafafa;
  border-radius: 6px;
}

.quick-label {
  font-size: 14px;
  color: #666;
  margin-right: 8px;
}

.report-summary {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
  margin-bottom: 24px;
}

.report-result {
  margin-top: 24px;
}
</style>

场景 4: 任务进度跟踪

vue
<template>
  <div class="task-tracking">
    <h3>任务进度跟踪</h3>
    <n-card>
      <div class="task-form">
        <n-form :model="taskForm" label-placement="left" label-width="100px">
          <n-form-item label="任务名称">
            <n-input
              v-model:value="taskForm.name"
              placeholder="请输入任务名称"
            />
          </n-form-item>

          <n-form-item label="计划时间">
            <C_Date
              mode="smart-range"
              start-placeholder="计划开始时间"
              end-placeholder="计划结束时间"
              :disabled-before-today="false"
              @update:smartRange="handlePlannedDateChange"
            />
          </n-form-item>

          <n-form-item label="实际时间">
            <C_Date
              mode="smart-range"
              start-placeholder="实际开始时间"
              end-placeholder="实际结束时间"
              :disabled-before-today="false"
              :start-date-props="actualStartDateProps"
              :end-date-props="actualEndDateProps"
              @update:smartRange="handleActualDateChange"
            />
          </n-form-item>

          <n-form-item label="任务状态">
            <n-select
              v-model:value="taskForm.status"
              :options="taskStatusOptions"
              placeholder="请选择任务状态"
            />
          </n-form-item>

          <n-form-item label="完成进度">
            <n-slider
              v-model:value="taskForm.progress"
              :step="5"
              :marks="progressMarks"
              :tooltip="false"
            />
            <span class="progress-text">{{ taskForm.progress }}%</span>
          </n-form-item>

          <n-form-item>
            <n-button type="primary" @click="handleUpdateTask"
              >更新任务</n-button
            >
            <n-button @click="handleAnalyzeTask" :disabled="!canAnalyze"
              >分析进度</n-button
            >
          </n-form-item>
        </n-form>
      </div>

      <!-- 任务分析结果 -->
      <div v-if="taskAnalysis" class="task-analysis">
        <n-divider title-placement="left">任务分析</n-divider>

        <div class="analysis-grid">
          <n-card size="small" title="时间对比">
            <div class="time-comparison">
              <div class="time-item">
                <span class="label">计划用时:</span>
                <span class="value">{{ taskAnalysis.plannedDuration }} 天</span>
              </div>
              <div class="time-item">
                <span class="label">实际用时:</span>
                <span class="value">{{ taskAnalysis.actualDuration }} 天</span>
              </div>
              <div class="time-item">
                <span class="label">时间偏差:</span>
                <span :class="['value', taskAnalysis.timeVariance.type]">
                  {{ taskAnalysis.timeVariance.text }}
                </span>
              </div>
            </div>
          </n-card>

          <n-card size="small" title="进度状态">
            <div class="progress-status">
              <n-progress
                type="circle"
                :percentage="taskForm.progress"
                :status="getProgressStatus()"
              />
              <div class="status-info">
                <p>当前状态: {{ getStatusText(taskForm.status) }}</p>
                <p>预计完成: {{ taskAnalysis.estimatedCompletion }}</p>
              </div>
            </div>
          </n-card>

          <n-card size="small" title="风险评估">
            <div class="risk-assessment">
              <n-tag :type="taskAnalysis.riskLevel.type" size="large">
                {{ taskAnalysis.riskLevel.text }}
              </n-tag>
              <p class="risk-description">{{ taskAnalysis.riskDescription }}</p>
            </div>
          </n-card>
        </div>
      </div>
    </n-card>
  </div>
</template>

<script setup>
const taskForm = ref({
  name: '',
  plannedDate: null,
  actualDate: null,
  status: 'not-started',
  progress: 0,
})

const taskAnalysis = ref(null)

const taskStatusOptions = [
  { label: '未开始', value: 'not-started' },
  { label: '进行中', value: 'in-progress' },
  { label: '已暂停', value: 'paused' },
  { label: '已完成', value: 'completed' },
  { label: '已取消', value: 'cancelled' },
]

const progressMarks = {
  0: '0%',
  25: '25%',
  50: '50%',
  75: '75%',
  100: '100%',
}

// 实际开始时间限制
const actualStartDateProps = computed(() => ({
  isDateDisabled: (timestamp) => {
    // 实际开始时间不能早于计划开始时间
    if (taskForm.value.plannedDate && taskForm.value.plannedDate[0]) {
      return timestamp < taskForm.value.plannedDate[0]
    }
    return false
  },
}))

// 实际结束时间限制
const actualEndDateProps = computed(() => ({
  isDateDisabled: (timestamp) => {
    // 实际结束时间不能早于实际开始时间
    if (taskForm.value.actualDate && taskForm.value.actualDate[0]) {
      return timestamp < taskForm.value.actualDate[0]
    }
    return false
  },
}))

const canAnalyze = computed(() => {
  return (
    taskForm.value.plannedDate &&
    taskForm.value.actualDate &&
    taskForm.value.actualDate[0]
  )
})

const handlePlannedDateChange = (value) => {
  taskForm.value.plannedDate = value
  // 重置实际时间
  taskForm.value.actualDate = null
}

const handleActualDateChange = (value) => {
  taskForm.value.actualDate = value
}

const handleUpdateTask = () => {
  if (!taskForm.value.name || !taskForm.value.plannedDate) {
    $message.warning('请填写任务名称和计划时间')
    return
  }

  $message.success('任务更新成功')

  // 如果可以分析,自动触发分析
  if (canAnalyze.value) {
    handleAnalyzeTask()
  }
}

const handleAnalyzeTask = () => {
  if (!canAnalyze.value) {
    $message.warning('请先填写完整的时间信息')
    return
  }

  const { plannedDate, actualDate, progress, status } = taskForm.value

  // 计算时间对比
  const plannedDuration =
    Math.ceil((plannedDate[1] - plannedDate[0]) / (1000 * 60 * 60 * 24)) + 1
  const actualStart = actualDate[0]
  const actualEnd = actualDate[1] || Date.now()
  const actualDuration =
    Math.ceil((actualEnd - actualStart) / (1000 * 60 * 60 * 24)) + 1

  const timeVariance = calculateTimeVariance(plannedDuration, actualDuration)
  const riskLevel = assessRisk(progress, status, timeVariance.value)
  const estimatedCompletion = calculateEstimatedCompletion(
    actualStart,
    progress
  )

  taskAnalysis.value = {
    plannedDuration,
    actualDuration,
    timeVariance,
    riskLevel,
    riskDescription: getRiskDescription(riskLevel),
    estimatedCompletion,
  }
}

const calculateTimeVariance = (planned, actual) => {
  const variance = actual - planned
  const percentage = ((variance / planned) * 100).toFixed(1)

  if (variance > 0) {
    return {
      value: variance,
      type: 'error',
      text: `延期 ${variance} 天 (+${percentage}%)`,
    }
  } else if (variance < 0) {
    return {
      value: variance,
      type: 'success',
      text: `提前 ${Math.abs(variance)} 天 (${percentage}%)`,
    }
  } else {
    return {
      value: 0,
      type: 'info',
      text: '按期进行',
    }
  }
}

const assessRisk = (progress, status, timeVariance) => {
  if (status === 'completed') {
    return { type: 'success', text: '已完成' }
  }

  if (status === 'cancelled') {
    return { type: 'error', text: '已取消' }
  }

  if (timeVariance > 5 || progress < 30) {
    return { type: 'error', text: '高风险' }
  } else if (timeVariance > 2 || progress < 50) {
    return { type: 'warning', text: '中等风险' }
  } else {
    return { type: 'success', text: '低风险' }
  }
}

const getRiskDescription = (riskLevel) => {
  const descriptions = {
    高风险: '任务存在较大延期风险,建议调整计划或增加资源',
    中等风险: '任务进度稍有滞后,需要密切关注',
    低风险: '任务进展良好,按计划进行',
    已完成: '任务已成功完成',
    已取消: '任务已被取消',
  }
  return descriptions[riskLevel.text] || ''
}

const calculateEstimatedCompletion = (actualStart, progress) => {
  if (progress >= 100) return '已完成'
  if (progress === 0) return '未开始'

  const now = Date.now()
  const daysPassed = Math.ceil((now - actualStart) / (1000 * 60 * 60 * 24))
  const totalEstimatedDays = Math.ceil((daysPassed / progress) * 100)
  const remainingDays = totalEstimatedDays - daysPassed

  const estimatedDate = new Date(now + remainingDays * 24 * 60 * 60 * 1000)
  return estimatedDate.toLocaleDateString('zh-CN')
}

const getProgressStatus = () => {
  const { progress, status } = taskForm.value

  if (status === 'completed') return 'success'
  if (status === 'cancelled') return 'error'
  if (progress < 30) return 'error'
  if (progress < 70) return 'warning'
  return 'info'
}

const getStatusText = (status) => {
  return (
    taskStatusOptions.find((option) => option.value === status)?.label || '未知'
  )
}

const formatDate = (timestamp) => {
  return new Date(timestamp).toLocaleDateString('zh-CN')
}
</script>

<style scoped>
.task-tracking {
  max-width: 1000px;
  margin: 0 auto;
  padding: 24px;
}

.task-form {
  margin-bottom: 24px;
}

.progress-text {
  margin-left: 12px;
  font-weight: bold;
  color: #1890ff;
}

.analysis-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 16px;
}

.time-comparison {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.time-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.label {
  color: #666;
}

.value {
  font-weight: bold;

  &.success {
    color: #52c41a;
  }

  &.error {
    color: #ff4d4f;
  }

  &.info {
    color: #1890ff;
  }
}

.progress-status {
  display: flex;
  align-items: center;
  gap: 16px;
}

.status-info p {
  margin: 4px 0;
}

.risk-assessment {
  text-align: center;
}

.risk-description {
  margin-top: 12px;
  color: #666;
  font-size: 14px;
}
</style>

🛠️ 高级用法

动态禁用规则

vue
<template>
  <div class="dynamic-disable">
    <n-switch v-model:value="weekendsOnly" @update:value="handleWeekendToggle">
      <template #checked>仅工作日</template>
      <template #unchecked>全部日期</template>
    </n-switch>

    <C_Date
      mode="daterange"
      :start-date-props="dynamicStartProps"
      :end-date-props="dynamicEndProps"
      @change="handleDateChange"
    />
  </div>
</template>

<script setup>
const weekendsOnly = ref(false)

const dynamicStartProps = computed(() => ({
  isDateDisabled: (timestamp) => {
    if (!weekendsOnly.value) return false

    const date = new Date(timestamp)
    const dayOfWeek = date.getDay()
    // 禁用周末(0=周日, 6=周六)
    return dayOfWeek === 0 || dayOfWeek === 6
  },
}))

const dynamicEndProps = computed(() => ({
  isDateDisabled: (timestamp) => {
    if (!weekendsOnly.value) return false

    const date = new Date(timestamp)
    const dayOfWeek = date.getDay()
    return dayOfWeek === 0 || dayOfWeek === 6
  },
}))

const handleWeekendToggle = (value) => {
  console.log('工作日模式:', value)
}

const handleDateChange = (value) => {
  console.log('日期变更:', value)
}
</script>

自定义格式化

vue
<template>
  <div class="custom-format">
    <n-radio-group
      v-model:value="dateFormat"
      @update:value="handleFormatChange"
    >
      <n-radio value="yyyy-MM-dd">标准格式 (2024-01-01)</n-radio>
      <n-radio value="yyyy/MM/dd">斜杠格式 (2024/01/01)</n-radio>
      <n-radio value="MM-dd-yyyy">美式格式 (01-01-2024)</n-radio>
      <n-radio value="dd/MM/yyyy">欧式格式 (01/01/2024)</n-radio>
    </n-radio-group>

    <C_Date
      mode="daterange"
      :value-format="dateFormat"
      @change="handleDateChange"
    />

    <p v-if="formattedResult">格式化结果: {{ formattedResult }}</p>
  </div>
</template>

<script setup>
const dateFormat = ref('yyyy-MM-dd')
const formattedResult = ref('')

const handleFormatChange = (format) => {
  console.log('格式变更:', format)
}

const handleDateChange = (value) => {
  if (value && Array.isArray(value)) {
    const [start, end] = value
    formattedResult.value = `${formatTimestamp(start)} 至 ${formatTimestamp(
      end
    )}`
  }
}

const formatTimestamp = (timestamp) => {
  const date = new Date(timestamp)
  return date.toLocaleDateString('zh-CN')
}
</script>

联动多个日期选择器

vue
<template>
  <div class="cascading-dates">
    <h4>项目阶段时间设置</h4>

    <div class="phase-dates">
      <div class="phase-item">
        <label>需求分析阶段</label>
        <C_Date
          mode="smart-range"
          start-placeholder="需求分析开始"
          end-placeholder="需求分析结束"
          :disabled-before-today="true"
          @update:smartRange="handlePhase1Change"
        />
      </div>

      <div class="phase-item">
        <label>开发阶段</label>
        <C_Date
          mode="smart-range"
          start-placeholder="开发开始"
          end-placeholder="开发结束"
          :start-date-props="phase2StartProps"
          :end-date-props="phase2EndProps"
          @update:smartRange="handlePhase2Change"
        />
      </div>

      <div class="phase-item">
        <label>测试阶段</label>
        <C_Date
          mode="smart-range"
          start-placeholder="测试开始"
          end-placeholder="测试结束"
          :start-date-props="phase3StartProps"
          :end-date-props="phase3EndProps"
          @update:smartRange="handlePhase3Change"
        />
      </div>
    </div>

    <!-- 时间线预览 -->
    <div v-if="hasAllPhases" class="timeline-preview">
      <h5>项目时间线预览</h5>
      <div class="timeline">
        <div
          v-for="phase in phaseTimeline"
          :key="phase.name"
          class="timeline-item"
        >
          <div class="phase-name">{{ phase.name }}</div>
          <div class="phase-duration">{{ phase.duration }}</div>
          <div class="phase-dates">{{ phase.dates }}</div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
const phase1Date = ref(null) // 需求分析
const phase2Date = ref(null) // 开发
const phase3Date = ref(null) // 测试

// 开发阶段不能早于需求分析结束
const phase2StartProps = computed(() => ({
  isDateDisabled: (timestamp) => {
    if (!phase1Date.value || !phase1Date.value[1]) return false
    return timestamp < phase1Date.value[1]
  },
}))

const phase2EndProps = computed(() => ({
  isDateDisabled: (timestamp) => {
    // 开发结束时间限制逻辑
    return false
  },
}))

// 测试阶段不能早于开发结束
const phase3StartProps = computed(() => ({
  isDateDisabled: (timestamp) => {
    if (!phase2Date.value || !phase2Date.value[1]) return false
    return timestamp < phase2Date.value[1]
  },
}))

const phase3EndProps = computed(() => ({
  isDateDisabled: (timestamp) => {
    // 测试结束时间限制逻辑
    return false
  },
}))

const hasAllPhases = computed(() => {
  return phase1Date.value && phase2Date.value && phase3Date.value
})

const phaseTimeline = computed(() => {
  if (!hasAllPhases.value) return []

  return [
    {
      name: '需求分析',
      duration: calculateDuration(phase1Date.value),
      dates: formatDateRange(phase1Date.value),
    },
    {
      name: '开发阶段',
      duration: calculateDuration(phase2Date.value),
      dates: formatDateRange(phase2Date.value),
    },
    {
      name: '测试阶段',
      duration: calculateDuration(phase3Date.value),
      dates: formatDateRange(phase3Date.value),
    },
  ]
})

const handlePhase1Change = (value) => {
  phase1Date.value = value
  // 重置后续阶段
  if (value) {
    phase2Date.value = null
    phase3Date.value = null
  }
}

const handlePhase2Change = (value) => {
  phase2Date.value = value
  // 重置测试阶段
  if (value) {
    phase3Date.value = null
  }
}

const handlePhase3Change = (value) => {
  phase3Date.value = value
}

const calculateDuration = (dateRange) => {
  if (!dateRange) return '未设置'
  const [start, end] = dateRange
  const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
  return `${days} 天`
}

const formatDateRange = (dateRange) => {
  if (!dateRange) return '未设置'
  const [start, end] = dateRange
  return `${formatDate(start)} - ${formatDate(end)}`
}

const formatDate = (timestamp) => {
  return new Date(timestamp).toLocaleDateString('zh-CN')
}
</script>

<style scoped>
.cascading-dates {
  max-width: 600px;
}

.phase-dates {
  display: flex;
  flex-direction: column;
  gap: 16px;
  margin: 16px 0;
}

.phase-item {
  display: flex;
  align-items: center;
  gap: 12px;
}

.phase-item label {
  min-width: 100px;
  font-weight: 500;
}

.timeline-preview {
  margin-top: 24px;
  padding: 16px;
  background: #fafafa;
  border-radius: 8px;
}

.timeline {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.timeline-item {
  display: grid;
  grid-template-columns: 100px 80px 1fr;
  gap: 12px;
  align-items: center;
  padding: 8px 0;
  border-bottom: 1px solid #e8e8e8;
}

.phase-name {
  font-weight: 500;
}

.phase-duration {
  color: #1890ff;
  font-weight: 500;
}

.phase-dates {
  color: #666;
}
</style>

🔧 自定义样式

CSS 变量

scss
.c-date-wrapper {
  --date-primary-color: #1890ff;
  --date-border-color: #d9d9d9;
  --date-hover-border-color: #40a9ff;
  --date-focus-border-color: #1890ff;
  --date-disabled-bg-color: #f5f5f5;
  --date-disabled-text-color: #ccc;
}

响应式布局

vue
<template>
  <C_Date mode="smart-range" class="responsive-date" />
</template>

<style scoped>
.responsive-date {
  width: 100%;

  :deep(.flex) {
    @media (max-width: 768px) {
      flex-direction: column;
      gap: 8px;
    }
  }

  :deep(.flex-1) {
    @media (max-width: 768px) {
      width: 100%;
    }
  }
}
</style>

自定义间距样式

vue
<template>
  <C_Date mode="smart-range" class="custom-spacing" />
</template>

<style scoped>
.custom-spacing :deep(.gap-2\.5) {
  gap: 16px;

  @media (max-width: 768px) {
    gap: 12px;
  }
}
</style>

⚠️ 注意事项

1. 模式选择

vue
<!-- ✅ 推荐:根据需求选择合适的模式 -->
<C_Date mode="smart-range" />
<!-- 需要联动限制时 -->
<C_Date mode="daterange" />
<!-- 简单范围选择时 -->

<!-- ❌ 不推荐:所有场景都使用同一种模式 -->
<C_Date mode="daterange" />
<!-- 所有场景 -->

2. 禁用规则设置

vue
<!-- ✅ 推荐:合理设置禁用规则 -->
<C_Date
  :disabled-before-today="true"  <!-- 预约类业务 -->
  :disabled-after-today="false"
/>

<!-- ❌ 不推荐:过度限制 -->
<C_Date
  :disabled-before-today="true"
  :disabled-after-today="true"  <!-- 只能选今天 -->
/>

3. 事件监听

vue
<!-- ✅ 推荐:监听具体事件 -->
<C_Date
  mode="smart-range"
  @update:smartRange="handleSmartRange"
/>

<!-- ❌ 不推荐:只监听通用事件 -->
<C_Date
  mode="smart-range"
  @change="handleChange"  <!-- 不如具体事件清晰 -->
/>

🐛 故障排除

常见问题

Q1: 智能范围模式结束日期无法选择?

A1: 检查以下几点:

  1. 确认已选择开始日期
  2. 检查禁用规则设置
  3. 确认事件监听正确
vue
<!-- 确保正确配置 -->
<C_Date mode="smart-range" @update:smartRange="handleSmartRange" />

<script setup>
const handleSmartRange = (value) => {
  console.log('智能范围变更:', value)
  // 确保事件处理函数存在
}
</script>

Q2: 日期格式显示不正确?

A2: 检查 valueFormat 设置:

vue
<!-- ✅ 正确的格式设置 -->
<C_Date
  mode="datetime"
  value-format="yyyy-MM-dd HH:mm:ss"
/>

<!-- ❌ 格式字符串错误 -->
<C_Date
  mode="datetime"
  value-format="YYYY-mm-dd"  <!-- 应该是 yyyy-MM-dd -->
/>

Q3: 禁用规则不生效?

A3: 确保使用正确的属性:

vue
<template>
  <C_Date
    mode="smart-range"
    :start-date-props="startProps"
    :end-date-props="endProps"
  />
</template>

<script setup>
const startProps = computed(() => ({
  isDateDisabled: (timestamp) => {
    // 禁用逻辑
    return false
  },
}))

const endProps = computed(() => ({
  isDateDisabled: (timestamp) => {
    // 禁用逻辑
    return false
  },
}))
</script>

Q4: 清空功能不工作?

A4: 使用暴露的 clearAll 方法:

vue
<template>
  <C_Date ref="datePickerRef" />
  <n-button @click="handleClear">清空日期</n-button>
</template>

<script setup>
const datePickerRef = ref()

const handleClear = () => {
  datePickerRef.value?.clearAll()
}
</script>

🎯 最佳实践

1. 根据业务场景选择模式

vue
<!-- ✅ 推荐:根据业务特点选择 -->
<!-- 预约业务:使用智能范围,禁用过去日期 -->
<C_Date mode="smart-range" :disabled-before-today="true" />

<!-- 报表查询:使用普通范围,允许历史日期 -->
<C_Date mode="daterange" :disabled-after-today="true" />

<!-- 生日选择:使用单日期,禁用未来日期 -->
<C_Date mode="date" :disabled-after-today="true" />

2. 合理的默认值设置

vue
<script setup>
const getIntelligentDefaults = () => {
  const now = new Date()
  const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000)
  const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)

  // 根据业务类型设置不同的默认值
  return {
    booking: [tomorrow.getTime(), nextWeek.getTime()], // 预约:明天到下周
    report: [
      new Date(now.getFullYear(), now.getMonth(), 1).getTime(),
      now.getTime(),
    ], // 报表:本月
    vacation: null, // 假期:无默认值,用户自选
  }
}
</script>

3. 表单验证集成

vue
<template>
  <n-form :model="formData" :rules="dateRules">
    <n-form-item label="活动时间" path="eventDate">
      <C_Date mode="smart-range" @update:smartRange="handleEventDateChange" />
    </n-form-item>
  </n-form>
</template>

<script setup>
const formData = ref({
  eventDate: null,
})

const dateRules = {
  eventDate: {
    validator: (rule, value) => {
      if (!value || !Array.isArray(value) || value.length !== 2) {
        return new Error('请选择完整的活动时间')
      }

      const [start, end] = value
      const duration = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1

      if (duration < 1) {
        return new Error('活动时间至少为1天')
      }

      if (duration > 30) {
        return new Error('活动时间不能超过30天')
      }

      return true
    },
    trigger: 'change',
  },
}

const handleEventDateChange = (value) => {
  formData.value.eventDate = value
}
</script>

4. 性能优化

vue
<script setup>
// 使用计算属性优化禁用函数
const disabledDateCache = new Map()

const optimizedDisabledDate = computed(() => {
  return (timestamp) => {
    // 使用缓存避免重复计算
    if (disabledDateCache.has(timestamp)) {
      return disabledDateCache.get(timestamp)
    }

    const result = calculateDisabledDate(timestamp)
    disabledDateCache.set(timestamp, result)
    return result
  }
})

// 清理缓存
onUnmounted(() => {
  disabledDateCache.clear()
})
</script>

📝 更新日志

v1.0.0 (2025-07-14)

  • ✨ 支持五种日期选择模式
  • ✨ 智能联动限制功能
  • ✨ 灵活的禁用规则配置
  • ✨ 完整的 TypeScript 支持
  • ✨ 丰富的事件系统
  • ✨ 暴露清空方法和内部状态

🤝 贡献指南

  1. Fork 项目
  2. 创建功能分支 (git checkout -b feature/amazing-feature)
  3. 提交更改 (git commit -m 'Add amazing feature')
  4. 推送到分支 (git push origin feature/amazing-feature)
  5. 创建 Pull Request

📄 许可证

Copyright (c) 2025 by ChenYu, All Rights Reserved.


💡 提示: 这个组件设计用于团队协作,支持假期申请、活动报名、财务报表、任务跟踪等多种场景。如果遇到问题请先查看文档,或者在团队群里讨论。让我们一起打造更好的开发体验! 🚀

Released under the MIT License