AppDatePicker.vue 6.96 KB
<template>
  <view class="app-date-picker">
    <view class="app-date-trigger" @click="openDialog">
      <text class="app-date-trigger-text">{{ displayText }}</text>
    </view>

    <view v-if="dialogVisible" class="app-date-mask" @click="cancelDialog">
      <view class="app-date-dialog" @click.stop>
        <text class="app-date-dialog-title">{{ dialogTitle }}</text>
        <picker-view
          class="app-date-picker-view"
          :indicator-style="indicatorStyle"
          :value="selection"
          @change="onPickerChange"
        >
          <picker-view-column>
            <view v-for="y in years" :key="'y-' + y" class="app-date-picker-item">{{ y }}</view>
          </picker-view-column>
          <picker-view-column>
            <view v-for="(m, idx) in monthLabels" :key="'m-' + idx" class="app-date-picker-item">{{ m }}</view>
          </picker-view-column>
          <picker-view-column>
            <view v-for="d in daysInMonth" :key="'d-' + d" class="app-date-picker-item">{{ d }}</view>
          </picker-view-column>
        </picker-view>
        <view class="app-date-actions">
          <view class="app-date-btn app-date-btn-cancel" @click="cancelDialog">
            <text>Cancel</text>
          </view>
          <view class="app-date-btn app-date-btn-confirm" @click="confirmDialog">
            <text>Confirm</text>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue'

const MONTH_LABELS = [
  'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
  'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
] as const

const props = withDefaults(
  defineProps<{
    modelValue: string
    /** yyyy-MM-dd */
    min?: string
    max?: string
    placeholder?: string
    dialogTitle?: string
  }>(),
  {
    modelValue: '',
    min: '2000-01-01',
    max: '2099-12-31',
    placeholder: 'Select',
    dialogTitle: 'Select date',
  },
)

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

const dialogVisible = ref(false)
const selection = ref<[number, number, number]>([0, 0, 0])
const draftY = ref(2026)
const draftM = ref(1)
const draftD = ref(1)

const indicatorStyle = 'height: 44px;'

const years = computed(() => {
  const minY = parseYmd(props.min).year
  const maxY = parseYmd(props.max).year
  const out: number[] = []
  for (let y = minY; y <= maxY; y++) out.push(y)
  return out.length ? out : [new Date().getFullYear()]
})

const monthLabels = MONTH_LABELS

const daysInMonth = computed(() => {
  const max = daysInMonthCount(draftY.value, draftM.value)
  return Array.from({ length: max }, (_, i) => String(i + 1).padStart(2, '0'))
})

const displayText = computed(() => {
  const v = (props.modelValue || '').trim()
  if (!v) return props.placeholder
  const p = parseYmd(v)
  if (!p.valid) return v
  return `${String(p.month).padStart(2, '0')}/${String(p.day).padStart(2, '0')}/${p.year}`
})

function parseYmd(raw: string | undefined): { year: number; month: number; day: number; valid: boolean } {
  const s = (raw || '').trim()
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
  if (!m) {
    const d = new Date()
    return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), valid: false }
  }
  return {
    year: Number(m[1]),
    month: Number(m[2]),
    day: Number(m[3]),
    valid: true,
  }
}

function daysInMonthCount(year: number, month: number): number {
  return new Date(year, month, 0).getDate()
}

function clampDate(y: number, mo: number, d: number): { y: number; mo: number; d: number } {
  const minP = parseYmd(props.min)
  const maxP = parseYmd(props.max)
  let year = y
  let month = mo
  let day = d
  const maxDay = daysInMonthCount(year, month)
  if (day > maxDay) day = maxDay

  const cur = new Date(year, month - 1, day)
  const minD = new Date(minP.year, minP.month - 1, minP.day)
  const maxD = new Date(maxP.year, maxP.month - 1, maxP.day)
  let clamped = cur
  if (cur < minD) clamped = minD
  if (cur > maxD) clamped = maxD

  return {
    y: clamped.getFullYear(),
    mo: clamped.getMonth() + 1,
    d: clamped.getDate(),
  }
}

function toYmd(y: number, mo: number, d: number): string {
  return `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`
}

function syncSelectionFromDate(y: number, mo: number, d: number) {
  const yi = Math.max(0, years.value.indexOf(y))
  const mi = Math.max(0, Math.min(11, mo - 1))
  const maxD = daysInMonthCount(y, mo)
  const day = Math.min(d, maxD)
  const di = Math.max(0, day - 1)
  draftY.value = y
  draftM.value = mo
  draftD.value = day
  selection.value = [yi, mi, di]
}

function openDialog() {
  const base = parseYmd(props.modelValue)
  const clamped = clampDate(base.year, base.month, base.day)
  syncSelectionFromDate(clamped.y, clamped.mo, clamped.d)
  dialogVisible.value = true
}

function onPickerChange(e: { detail: { value: number[] } }) {
  const arr = e.detail.value || [0, 0, 0]
  const y = years.value[arr[0]] ?? draftY.value
  const mo = (arr[1] ?? 0) + 1
  let d = (arr[2] ?? 0) + 1
  const clamped = clampDate(y, mo, d)
  syncSelectionFromDate(clamped.y, clamped.mo, clamped.d)
}

function cancelDialog() {
  dialogVisible.value = false
}

function confirmDialog() {
  const v = toYmd(draftY.value, draftM.value, draftD.value)
  emit('update:modelValue', v)
  dialogVisible.value = false
}

watch(
  () => props.modelValue,
  (v) => {
    if (dialogVisible.value) return
    const p = parseYmd(v)
    if (p.valid) {
      const c = clampDate(p.year, p.month, p.day)
      draftY.value = c.y
      draftM.value = c.mo
      draftD.value = c.d
    }
  },
)
</script>

<style scoped>
.app-date-trigger {
  height: 72rpx;
  padding: 0 24rpx;
  background: #fff;
  border: 2rpx solid #e5e7eb;
  border-radius: 12rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
}

.app-date-trigger-text {
  font-size: 28rpx;
  color: #111827;
}

.app-date-mask {
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  z-index: 1000;
  background: rgba(0, 0, 0, 0.45);
  display: flex;
  align-items: flex-end;
  justify-content: center;
}

.app-date-dialog {
  width: 100%;
  background: #fff;
  border-radius: 24rpx 24rpx 0 0;
  padding: 28rpx 32rpx calc(28rpx + env(safe-area-inset-bottom));
  box-sizing: border-box;
}

.app-date-dialog-title {
  display: block;
  text-align: center;
  font-size: 32rpx;
  font-weight: 600;
  color: #111827;
  margin-bottom: 16rpx;
}

.app-date-picker-view {
  width: 100%;
  height: 440rpx;
}

.app-date-picker-item {
  height: 44px;
  line-height: 44px;
  text-align: center;
  font-size: 30rpx;
  color: #111827;
}

.app-date-actions {
  display: flex;
  gap: 24rpx;
  margin-top: 20rpx;
}

.app-date-btn {
  flex: 1;
  height: 88rpx;
  border-radius: 16rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 30rpx;
  font-weight: 600;
}

.app-date-btn-cancel {
  border: 2rpx solid #d1d5db;
  color: #374151;
  background: #fff;
}

.app-date-btn-confirm {
  background: var(--theme-primary, #4f46e5);
  color: #fff;
}
</style>