From 26b7f552f063c68e05b1ee14a9514baed33a5807 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sat, 11 Jul 2026 10:57:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(datepicker):=20=E5=BC=B9=E5=B1=82=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20Portal+fixed=20=E5=AE=9A=E4=BD=8D,=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=A2=AB=E5=B8=83=E5=B1=80=E9=81=AE=E6=8C=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 日期选择器弹层在参数优化页等含 overflow:hidden / transform 的容器内会被 裁剪或遮挡。改用 createPortal 渲染到 body, 配合 fixed 定位 + 视口坐标计算: - 打开时按按钮位置计算坐标, 下方不足自动向上翻转, 水平溢出回拉 - 滚动/resize 时关闭弹层 (fixed 不跟随滚动, 关闭比重算更可靠) - 外部点击检测适配 Portal (分别判断按钮与弹层 ref) --- frontend/src/components/DatePicker.tsx | 84 +++++++++++++++++++++----- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/DatePicker.tsx b/frontend/src/components/DatePicker.tsx index ef4fe81..c0ea9ed 100644 --- a/frontend/src/components/DatePicker.tsx +++ b/frontend/src/components/DatePicker.tsx @@ -1,7 +1,15 @@ import { useState, useRef, useEffect } from 'react' +import { createPortal } from 'react-dom' import { motion, AnimatePresence } from 'framer-motion' import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react' +/** 弹层固定宽度(px) — 与下方 w-[260px] 保持一致,用于水平边界裁剪计算 */ +const POPUP_WIDTH = 260 +/** 弹层预估高度(px) — 用于判断是否需要向上翻转 */ +const POPUP_HEIGHT = 320 +/** 弹层与触发按钮的间距(px) */ +const POPUP_GAP = 6 + interface DatePickerProps { value: string // YYYY-MM-DD onChange: (v: string) => void @@ -43,7 +51,12 @@ export function DatePicker({ }: DatePickerProps) { const [open, setOpen] = useState(false) const [showYearPicker, setShowYearPicker] = useState(false) - const ref = useRef(null) + // 触发按钮 ref (外部点击检测 + 计算弹层坐标) + const btnRef = useRef(null) + // 弹层 portal ref (外部点击检测) + const popRef = useRef(null) + // 弹层视口坐标 + 展开方向(open 时计算一次,避免滚动时漂移) + const [pos, setPos] = useState<{ top: number; left: number } | null>(null) // 当前显示的月份 const [viewYear, setViewYear] = useState(() => viewDate(value, min, max).year) @@ -56,16 +69,50 @@ export function DatePicker({ setViewMonth(next.month) }, [value, min, max]) - // 点击外部关闭 + // 打开弹层: 按钮的视口坐标计算 + 智能方向翻转 + 水平边界裁剪 + const handleOpen = () => { + if (open) { setOpen(false); return } + if (!btnRef.current) { setOpen(true); return } + const r = btnRef.current.getBoundingClientRect() + const spaceBelow = window.innerHeight - r.bottom + // 下方空间不足 → 向上展开 + const dropUp = spaceBelow < POPUP_HEIGHT + POPUP_GAP && r.top > POPUP_HEIGHT + POPUP_GAP + const top = dropUp + ? Math.max(8, r.top - POPUP_HEIGHT - POPUP_GAP) + : r.bottom + POPUP_GAP + // 水平: 默认按 align 贴齐按钮左/右边缘, 再做右侧溢出裁剪 + const rawLeft = align === 'left' ? r.left : r.right - POPUP_WIDTH + const left = Math.max(8, Math.min(rawLeft, window.innerWidth - POPUP_WIDTH - 8)) + setPos({ top, left }) + setOpen(true) + } + + // 点击外部关闭 (Portal 下弹层不在 ref 树内, 分别判断按钮与弹层) useEffect(() => { if (!open) return const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + const t = e.target as Node + if (btnRef.current?.contains(t)) return + if (popRef.current?.contains(t)) return + setOpen(false) } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [open]) + // 滚动 / resize 时关闭弹层 (fixed 定位不跟随滚动,关闭比重算更可靠) + useEffect(() => { + if (!open) return + const close = () => setOpen(false) + // capture: true → 捕获到任意祖先滚动容器的 scroll + window.addEventListener('scroll', close, true) + window.addEventListener('resize', close) + return () => { + window.removeEventListener('scroll', close, true) + window.removeEventListener('resize', close) + } + }, [open]) + const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1) } else setViewMonth(viewMonth - 1) @@ -109,11 +156,12 @@ export function DatePicker({ const today = todayStr() return ( -
+
{/* 触发按钮 */} - {/* 弹出日历 */} - - {open && ( - + {/* 弹出日历 — Portal 到 body, 逃逸祖先 overflow 裁剪与 framer-motion transform 包含块 */} + {createPortal( + + {open && pos && ( + {/* 月份导航 */}
) }