import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import Taro from '@tarojs/taro' import { View, Text, ScrollView } from '@tarojs/components' import styles from './calendar-drawer.module.scss' // 日历可回溯年数(约束最早月份,不影响性能) const CALENDAR_YEARS_BACK = 10 const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'] // 懒渲染窗口参数(rpx):可视高度近似 + 前后缓冲 const VIEW_RPX = 1400 const BUFFER_RPX = 3000 const pad2 = (n: number) => String(n).padStart(2, '0') const fmtDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` const parseDate = (s: string) => { const [y, m, d] = s.split('-').map(Number) return new Date(y, m - 1, d) } const quickRange = (days: number) => { const end = new Date() const start = new Date() start.setDate(start.getDate() - (days - 1)) return { label: `近${days}天`, start: fmtDate(start), end: fmtDate(end) } } interface MonthMeta { key: string year: number month: number title: string top: number height: number } interface Cell { day: number date?: string disabled?: boolean dayCls?: string tag?: string } interface Props { initialStart: string initialEnd: string onClose: () => void onConfirm: (start: string, end: string) => void } function monthWeeks(y: number, m: number) { const firstDay = new Date(y, m, 1) const lead = (firstDay.getDay() + 6) % 7 const daysInMonth = new Date(y, m + 1, 0).getDate() return Math.ceil((lead + daysInMonth) / 7) } export function CalendarDrawer({ initialStart, initialEnd, onClose, onConfirm }: Props) { const [windowWidth, setWindowWidth] = useState(375) const [calScroll, setCalScroll] = useState(0) const [calAnchorId, setCalAnchorId] = useState('') const [pendingStart, setPendingStart] = useState(initialStart) const [pendingEnd, setPendingEnd] = useState(initialEnd) const [activeQuick, setActiveQuick] = useState('') const anchorTimer = useRef | null>(null) const quickRanges = useMemo(() => [quickRange(7), quickRange(30), quickRange(90)], []) const minLimit = useMemo(() => { const d = new Date() d.setFullYear(d.getFullYear() - CALENDAR_YEARS_BACK) d.setDate(1) return fmtDate(d) }, []) const maxLimit = useMemo(() => fmtDate(new Date()), []) // 全部月份元数据:每月份含估算高度(标题行 + 周数×行高) const monthMetas = useMemo(() => { const metas: MonthMeta[] = [] const start = parseDate(minLimit) const now = new Date() let y = start.getFullYear() let m = start.getMonth() let top = 0 while (y < now.getFullYear() || (y === now.getFullYear() && m <= now.getMonth())) { const weeks = monthWeeks(y, m) const height = 96 + weeks * Math.round((750 / 7) * 1.15) + 16 metas.push({ key: `m-${y}-${m}`, year: y, month: m, title: `${y}年${pad2(m + 1)}月`, top, height }) top += height if (m === 11) { y += 1; m = 0 } else { m += 1 } } return metas }, [minLimit]) // px→rpx 换算基准 + 打开时定位 useEffect(() => { setWindowWidth(Taro.getWindowInfo().windowWidth || 375) const focus = initialStart || fmtDate(new Date()) const d = parseDate(focus) const meta = monthMetas.find((mm) => mm.year === d.getFullYear() && mm.month === d.getMonth()) setCalAnchorId('') // scroll-into-view 需要值变化才生效:先清空再在下个循环赋值 const timer = setTimeout(() => setCalAnchorId(meta ? meta.key : ''), 0) return () => clearTimeout(timer) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // 单格数据:区间内/端点/禁用态一次算好 const buildCell = useCallback((dateStr: string, day: number): Cell => { const disabled = dateStr < minLimit || dateStr > maxLimit let dayCls = '' let tag = '' if (!disabled) { if (pendingStart && dateStr === pendingStart) { dayCls = 'start' tag = pendingStart === pendingEnd ? '起/止' : '开始' } else if (pendingEnd && dateStr === pendingEnd) { dayCls = 'end' tag = '结束' } else if (pendingStart && pendingEnd && dateStr > pendingStart && dateStr < pendingEnd) { dayCls = 'in' } } return { day, date: dateStr, disabled, dayCls, tag } }, [minLimit, maxLimit, pendingStart, pendingEnd]) const buildMonthCells = useCallback((y: number, m: number): Cell[] => { const firstDay = new Date(y, m, 1) const lead = (firstDay.getDay() + 6) % 7 const daysInMonth = new Date(y, m + 1, 0).getDate() const cells: Cell[] = [] for (let i = 0; i < lead; i++) cells.push({ day: 0 }) for (let d = 1; d <= daysInMonth; d++) { cells.push(buildCell(`${y}-${pad2(m + 1)}-${pad2(d)}`, d)) } return cells }, [buildCell]) // 懒渲染:仅渲染滚动窗口(±BUFFER_RPX)内月份,窗口外等高占位 const renderedMonths = useMemo(() => { const lo = calScroll - BUFFER_RPX const hi = calScroll + VIEW_RPX + BUFFER_RPX return monthMetas.map((meta) => { const inWindow = meta.top + meta.height > lo && meta.top < hi if (!inWindow) return { ...meta, placeholder: true as const, cells: [] as Cell[] } return { ...meta, placeholder: false as const, cells: buildMonthCells(meta.year, meta.month) } }) }, [monthMetas, calScroll, buildMonthCells]) // 滚动:px → rpx(750/窗口宽),跨过 200rpx 再更新,减少重算 const onCalScroll = (e: { detail: { scrollTop: number } }) => { const px = e.detail.scrollTop || 0 const rpx = px * (750 / windowWidth) setCalScroll((prev) => (Math.abs(rpx - prev) > 200 ? rpx : prev)) } // CSS Modules 动态 key 映射对象(避免索引签名问题) const dayClsMap: Record = { start: styles.dayStart, end: styles.dayEnd, in: styles.dayIn } // 第一击定起点,第二击定终点(早于起点则重定起点),选满再点重选 const pickDate = (cell: Cell) => { if (!cell.day || cell.disabled || !cell.date) return const date = cell.date setActiveQuick('') if (!pendingStart || (pendingStart && pendingEnd)) { setPendingStart(date) setPendingEnd('') } else if (date < pendingStart) { setPendingStart(date) } else { setPendingEnd(date) } } // 快捷范围:填满起止并定位起点月 const applyQuick = (q: { label: string; start: string; end: string }) => { setPendingStart(q.start) setPendingEnd(q.end) setActiveQuick(q.label) const d = parseDate(q.start) const meta = monthMetas.find((mm) => mm.year === d.getFullYear() && mm.month === d.getMonth()) setCalAnchorId('') if (anchorTimer.current) clearTimeout(anchorTimer.current) anchorTimer.current = setTimeout(() => setCalAnchorId(meta ? meta.key : ''), 0) } useEffect(() => () => { if (anchorTimer.current) clearTimeout(anchorTimer.current) }, []) const rangeReady = !!pendingStart && !!pendingEnd return ( 选择时间范围 × {quickRanges.map((q) => ( applyQuick(q)} > {q.label} ))} {WEEKDAY_LABELS.map((w) => ( {w} ))} {renderedMonths.map((m) => ( {!m.placeholder && ( <> {m.title} {m.cells.map((cell, i) => ( pickDate(cell)} > {!!cell.day && ( {cell.day} {!!cell.tag && {cell.tag}} )} ))} )} ))} rangeReady && onConfirm(pendingStart, pendingEnd)} > {rangeReady ? '确定' : '请选择起止日期'} ) }