277 lines
9.6 KiB
TypeScript
277 lines
9.6 KiB
TypeScript
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<ReturnType<typeof setTimeout> | 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<MonthMeta[]>(() => {
|
||
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<string, string> = {
|
||
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 (
|
||
<View className={styles.dropPanelCalendar}>
|
||
<View className={styles.header}>
|
||
<Text className={styles.title}>选择时间范围</Text>
|
||
<View className={styles.close} onClick={onClose}>
|
||
<Text className={styles.closeIcon}>×</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View className={styles.quickRow}>
|
||
{quickRanges.map((q) => (
|
||
<View
|
||
key={q.label}
|
||
className={`${styles.quickChip} ${activeQuick === q.label ? styles.quickChipActive : ''}`}
|
||
onClick={() => applyQuick(q)}
|
||
>
|
||
<Text>{q.label}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
|
||
<View className={styles.weekdays}>
|
||
{WEEKDAY_LABELS.map((w) => (
|
||
<Text key={w} className={styles.weekday}>{w}</Text>
|
||
))}
|
||
</View>
|
||
|
||
<View className={styles.scrollWrap}>
|
||
<ScrollView
|
||
className={styles.scroll}
|
||
scrollY
|
||
scrollIntoView={calAnchorId}
|
||
scrollAnchoring
|
||
onScroll={onCalScroll}
|
||
>
|
||
{renderedMonths.map((m) => (
|
||
<View
|
||
key={m.key}
|
||
id={m.key}
|
||
className={styles.month}
|
||
style={m.placeholder ? { height: `${m.height}rpx` } : undefined}
|
||
>
|
||
{!m.placeholder && (
|
||
<>
|
||
<View className={styles.monthTitle}>
|
||
<Text>{m.title}</Text>
|
||
</View>
|
||
<View className={styles.grid}>
|
||
{m.cells.map((cell, i) => (
|
||
<View
|
||
key={i}
|
||
className={`${styles.cell} ${cell.disabled ? styles.cellDisabled : ''}`}
|
||
onClick={() => pickDate(cell)}
|
||
>
|
||
{!!cell.day && (
|
||
<View className={`${styles.day} ${cell.dayCls ? dayClsMap[cell.dayCls] : ''}`}>
|
||
<Text className={styles.dayNum}>{cell.day}</Text>
|
||
{!!cell.tag && <Text className={styles.dayTag}>{cell.tag}</Text>}
|
||
</View>
|
||
)}
|
||
</View>
|
||
))}
|
||
</View>
|
||
</>
|
||
)}
|
||
</View>
|
||
))}
|
||
</ScrollView>
|
||
</View>
|
||
|
||
<View className={styles.footer}>
|
||
<View
|
||
className={`${styles.confirmBtn} ${!rangeReady ? styles.confirmDisabled : ''}`}
|
||
onClick={() => rangeReady && onConfirm(pendingStart, pendingEnd)}
|
||
>
|
||
<Text className={styles.confirmText}>{rangeReady ? '确定' : '请选择起止日期'}</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
)
|
||
}
|