SEC_Warehouse/src/pages/event/calendar-drawer.tsx

277 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}