diff --git a/app/components/ConfirmModal.jsx b/app/components/ConfirmModal.jsx index 69f42e1..4f1e1f8 100644 --- a/app/components/ConfirmModal.jsx +++ b/app/components/ConfirmModal.jsx @@ -1,59 +1,55 @@ 'use client'; -import { motion } from 'framer-motion'; -import { createPortal } from 'react-dom'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { TrashIcon } from './Icons'; -export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) { - const content = ( - { - e.stopPropagation(); - onCancel(); - }} - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - exit={{ opacity: 0 }} - style={{ zIndex: 10002 }} - > - e.stopPropagation()} +export default function ConfirmModal({ + title, + message, + onConfirm, + onCancel, + confirmText = '确定删除', +}) { + const handleOpenChange = (open) => { + if (!open) onCancel(); + }; + + return ( + + -
- - {title} -
-

+ + + {title} + + {message} -

-
+ +
- - + +
); - if (typeof document === 'undefined') return null; - return createPortal(content, document.body); } diff --git a/app/components/EmptyStateCard.jsx b/app/components/EmptyStateCard.jsx new file mode 100644 index 0000000..2c31070 --- /dev/null +++ b/app/components/EmptyStateCard.jsx @@ -0,0 +1,33 @@ +'use client'; + +export default function EmptyStateCard({ + fundsLength = 0, + currentTab = 'all', + onAddToGroup, +}) { + const isEmpty = fundsLength === 0; + const isGroupTab = currentTab !== 'all' && currentTab !== 'fav'; + + return ( +
+
📂
+
+ {isEmpty ? '尚未添加基金' : '该分组下暂无数据'} +
+ {isGroupTab && fundsLength > 0 && ( + + )} +
+ ); +} diff --git a/app/components/FundCard.jsx b/app/components/FundCard.jsx new file mode 100644 index 0000000..0d0058d --- /dev/null +++ b/app/components/FundCard.jsx @@ -0,0 +1,459 @@ +'use client'; + +import { motion, AnimatePresence } from 'framer-motion'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; +import { isNumber, isString } from 'lodash'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Stat } from './Common'; +import FundTrendChart from './FundTrendChart'; +import FundIntradayChart from './FundIntradayChart'; +import { + ChevronIcon, + ExitIcon, + SettingsIcon, + StarIcon, + SwitchIcon, + TrashIcon, +} from './Icons'; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(isSameOrAfter); + +const DEFAULT_TZ = 'Asia/Shanghai'; +const getBrowserTimeZone = () => { + if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + return tz || DEFAULT_TZ; + } + return DEFAULT_TZ; +}; +const TZ = getBrowserTimeZone(); +const toTz = (input) => (input ? dayjs.tz(input, TZ) : dayjs().tz(TZ)); + +export default function FundCard({ + fund: f, + todayStr, + currentTab, + favorites, + dcaPlans, + holdings, + percentModes, + valuationSeries, + collapsedCodes, + collapsedTrends, + transactions, + theme, + isTradingDay, + refreshing, + getHoldingProfit, + onRemoveFromGroup, + onToggleFavorite, + onRemoveFund, + onHoldingClick, + onActionClick, + onPercentModeToggle, + onToggleCollapse, + onToggleTrendCollapse, + layoutMode = 'card', // 'card' | 'drawer',drawer 时前10重仓与业绩走势以 Tabs 展示 +}) { + const holding = holdings[f?.code]; + const profit = getHoldingProfit?.(f, holding) ?? null; + const hasHoldings = f.holdingsIsLastQuarter && Array.isArray(f.holdings) && f.holdings.length > 0; + + const style = layoutMode === 'drawer' ? { border: 'none', boxShadow: 'none' } : {}; + + return ( + +
+
+ {currentTab !== 'all' && currentTab !== 'fav' ? ( + + ) : ( + + )} +
+ + {f.name} + + + #{f.code} + {dcaPlans?.[f.code]?.enabled === true && } + {f.jzrq === todayStr && } + +
+
+ +
+
+ {f.noValuation ? '净值日期' : '估值时间'} + {f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')} +
+
+ +
+
+
+ +
+ + {f.noValuation ? ( + 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` + : '—' + } + delta={f.zzl} + /> + ) : ( + <> + {(() => { + const hasTodayData = f.jzrq === todayStr; + let isYesterdayChange = false; + let isPreviousTradingDay = false; + if (!hasTodayData && isString(f.jzrq)) { + const today = toTz(todayStr).startOf('day'); + const jzDate = toTz(f.jzrq).startOf('day'); + const yesterday = today.clone().subtract(1, 'day'); + if (jzDate.isSame(yesterday, 'day')) { + isYesterdayChange = true; + } else if (jzDate.isBefore(yesterday, 'day')) { + isPreviousTradingDay = true; + } + } + const shouldHideChange = + isTradingDay && !hasTodayData && !isYesterdayChange && !isPreviousTradingDay; + + if (shouldHideChange) return null; + + const changeLabel = hasTodayData ? '涨跌幅' : '昨日涨幅'; + return ( + 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` + : '' + } + delta={f.zzl} + /> + ); + })()} + 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—') + } + /> + 0.05 + ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` + : isNumber(f.gszzl) + ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` + : f.gszzl ?? '—' + } + delta={f.estPricedCoverage > 0.05 ? f.estGszzl : Number(f.gszzl) || 0} + /> + + )} +
+ +
+ {!profit ? ( +
+ 持仓金额 +
onHoldingClick?.(f)} + > + 未设置 +
+
+ ) : ( + <> +
onActionClick?.(f)} + > + + 持仓金额 + + ¥{profit.amount.toFixed(2)} +
+
+ 当日收益 + 0 + ? 'up' + : profit.profitToday < 0 + ? 'down' + : '' + : 'muted' + }`} + > + {profit.profitToday != null + ? `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}` + : '--'} + +
+ {profit.profitTotal !== null && ( +
{ + e.stopPropagation(); + onPercentModeToggle?.(f.code); + }} + style={{ cursor: 'pointer', flexDirection: 'column', gap: 4 }} + title="点击切换金额/百分比" + > + + 持有收益{percentModes?.[f.code] ? '(%)' : ''} + + + 0 ? 'up' : profit.profitTotal < 0 ? 'down' : '' + }`} + > + {profit.profitTotal > 0 ? '+' : profit.profitTotal < 0 ? '-' : ''} + {percentModes?.[f.code] + ? `${Math.abs( + holding?.cost * holding?.share + ? (profit.profitTotal / (holding.cost * holding.share)) * 100 + : 0, + ).toFixed(2)}%` + : `¥${Math.abs(profit.profitTotal).toFixed(2)}`} + +
+ )} + + )} +
+ + {f.estPricedCoverage > 0.05 && ( +
+ 基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算 +
+ )} + + {(() => { + const showIntraday = + Array.isArray(valuationSeries?.[f.code]) && valuationSeries[f.code].length >= 2; + if (!showIntraday) return null; + + if ( + f.gztime && + toTz(todayStr).startOf('day').isAfter(toTz(f.gztime).startOf('day')) + ) { + return null; + } + + if ( + f.jzrq && + f.gztime && + toTz(f.jzrq).startOf('day').isSameOrAfter(toTz(f.gztime).startOf('day')) + ) { + return null; + } + + return ( + + ); + })()} + + {layoutMode === 'drawer' ? ( + + + {hasHoldings && ( + 前10重仓股票 + )} + 业绩走势 + + {hasHoldings && ( + +
+ {f.holdings.map((h, idx) => ( +
+ {h.name} +
+ {isNumber(h.change) && ( + 0 ? 'up' : h.change < 0 ? 'down' : ''}`} + style={{ marginRight: 8 }} + > + {h.change > 0 ? '+' : ''} + {h.change.toFixed(2)}% + + )} + {h.weight} +
+
+ ))} +
+
+ )} + + onToggleTrendCollapse?.(f.code)} + transactions={transactions?.[f.code] || []} + theme={theme} + hideHeader + /> + +
+ ) : ( + <> + {hasHoldings && ( + <> +
onToggleCollapse?.(f.code)} + > +
+
+ 前10重仓股票 + +
+ 涨跌幅 / 占比 +
+
+ + {!collapsedCodes?.has(f.code) && ( + +
+ {f.holdings.map((h, idx) => ( +
+ {h.name} +
+ {isNumber(h.change) && ( + 0 ? 'up' : h.change < 0 ? 'down' : ''}`} + style={{ marginRight: 8 }} + > + {h.change > 0 ? '+' : ''} + {h.change.toFixed(2)}% + + )} + {h.weight} +
+
+ ))} +
+
+ )} +
+ + )} + onToggleTrendCollapse?.(f.code)} + transactions={transactions?.[f.code] || []} + theme={theme} + /> + + )} +
+ ); +} diff --git a/app/components/FundTrendChart.jsx b/app/components/FundTrendChart.jsx index 238add2..129dccb 100644 --- a/app/components/FundTrendChart.jsx +++ b/app/components/FundTrendChart.jsx @@ -54,7 +54,7 @@ function getChartThemeColors(theme) { return CHART_COLORS[theme] || CHART_COLORS.dark; } -export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark' }) { +export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark', hideHeader = false }) { const [range, setRange] = useState('1m'); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); @@ -490,79 +490,102 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans }]; }, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair - return ( -
e.stopPropagation()}> -
-
-
- 业绩走势 - + const chartBlock = ( + <> +
+ {loading && ( +
+ 加载中...
- {data.length > 0 && ( -
- {ranges.find(r => r.value === range)?.label}涨跌幅 - - {change > 0 ? '+' : ''}{change.toFixed(2)}% - -
- )} -
+ )} + + {!loading && data.length === 0 && ( +
+ 暂无数据 +
+ )} + + {data.length > 0 && ( + + )}
- - {isExpanded && ( - + {ranges.map(r => ( + + ))} +
+ + ); - {!loading && data.length === 0 && ( -
- 暂无数据 -
- )} - - {data.length > 0 && ( - - )} + return ( +
e.stopPropagation()}> + {!hideHeader && ( +
+
+
+ 业绩走势 +
+ {data.length > 0 && ( +
+ {ranges.find(r => r.value === range)?.label}涨跌幅 + + {change > 0 ? '+' : ''}{change.toFixed(2)}% + +
+ )} +
+
+ )} -
- {ranges.map(r => ( - - ))} -
- - )} - + {hideHeader && data.length > 0 && ( +
+
+ {ranges.find(r => r.value === range)?.label}涨跌幅 + + {change > 0 ? '+' : ''}{change.toFixed(2)}% + +
+
+ )} + + {hideHeader ? ( + chartBlock + ) : ( + + {isExpanded && ( + + {chartBlock} + + )} + + )}
); } diff --git a/app/components/GroupSummary.jsx b/app/components/GroupSummary.jsx new file mode 100644 index 0000000..524cb43 --- /dev/null +++ b/app/components/GroupSummary.jsx @@ -0,0 +1,332 @@ +'use client'; + +import { useEffect, useRef, useState, useMemo, useLayoutEffect } from 'react'; +import { PinIcon, PinOffIcon, EyeIcon, EyeOffIcon, SwitchIcon } from './Icons'; + +// 数字滚动组件(初始化时无动画,后续变更再动画) +function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = '', style = {} }) { + const [displayValue, setDisplayValue] = useState(value); + const previousValue = useRef(value); + const isFirstChange = useRef(true); + + useEffect(() => { + if (previousValue.current === value) return; + + if (isFirstChange.current) { + isFirstChange.current = false; + previousValue.current = value; + setDisplayValue(value); + return; + } + + const start = previousValue.current; + const end = value; + const duration = 400; + const startTime = performance.now(); + + const animate = (currentTime) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const ease = 1 - Math.pow(1 - progress, 4); + const current = start + (end - start) * ease; + setDisplayValue(current); + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + previousValue.current = value; + } + }; + + requestAnimationFrame(animate); + }, [value]); + + return ( + + {prefix} + {Math.abs(displayValue).toFixed(decimals)} + {suffix} + + ); +} + +export default function GroupSummary({ + funds, + holdings, + groupName, + getProfit, + stickyTop, +}) { + const [showPercent, setShowPercent] = useState(true); + const [isMasked, setIsMasked] = useState(false); + const [isSticky, setIsSticky] = useState(false); + const rowRef = useRef(null); + const [assetSize, setAssetSize] = useState(24); + const [metricSize, setMetricSize] = useState(18); + const [winW, setWinW] = useState(0); + + useEffect(() => { + if (typeof window !== 'undefined') { + setWinW(window.innerWidth); + const onR = () => setWinW(window.innerWidth); + window.addEventListener('resize', onR); + return () => window.removeEventListener('resize', onR); + } + }, []); + + const summary = useMemo(() => { + let totalAsset = 0; + let totalProfitToday = 0; + let totalHoldingReturn = 0; + let totalCost = 0; + let hasHolding = false; + let hasAnyTodayData = false; + + funds.forEach((fund) => { + const holding = holdings[fund.code]; + const profit = getProfit(fund, holding); + + if (profit) { + hasHolding = true; + totalAsset += profit.amount; + if (profit.profitToday != null) { + totalProfitToday += Math.round(profit.profitToday * 100) / 100; + hasAnyTodayData = true; + } + if (profit.profitTotal !== null) { + totalHoldingReturn += profit.profitTotal; + if (holding && typeof holding.cost === 'number' && typeof holding.share === 'number') { + totalCost += holding.cost * holding.share; + } + } + } + }); + + const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0; + + return { + totalAsset, + totalProfitToday, + totalHoldingReturn, + hasHolding, + returnRate, + hasAnyTodayData, + }; + }, [funds, holdings, getProfit]); + + useLayoutEffect(() => { + const el = rowRef.current; + if (!el) return; + const height = el.clientHeight; + const tooTall = height > 80; + if (tooTall) { + setAssetSize((s) => Math.max(16, s - 1)); + setMetricSize((s) => Math.max(12, s - 1)); + } + }, [ + winW, + summary.totalAsset, + summary.totalProfitToday, + summary.totalHoldingReturn, + summary.returnRate, + showPercent, + assetSize, + metricSize, + ]); + + if (!summary.hasHolding) return null; + + return ( +
+
+ setIsSticky(!isSticky)} + style={{ + position: 'absolute', + top: 4, + right: 4, + width: 24, + height: 24, + padding: 4, + opacity: 0.6, + zIndex: 10, + color: 'var(--muted)', + }} + > + {isSticky ? ( + + ) : ( + + )} + +
+
+
+
+ {groupName} +
+ +
+
+ ¥ + {isMasked ? ( + + ****** + + ) : ( + + )} +
+
+
+
+
+ 当日收益 +
+
0 + ? 'up' + : summary.totalProfitToday < 0 + ? 'down' + : '' + : 'muted' + } + style={{ + fontSize: '18px', + fontWeight: 700, + fontFamily: 'var(--font-mono)', + }} + > + {isMasked ? ( + ****** + ) : summary.hasAnyTodayData ? ( + <> + + {summary.totalProfitToday > 0 + ? '+' + : summary.totalProfitToday < 0 + ? '-' + : ''} + + + + ) : ( + -- + )} +
+
+
+
+ 持有收益{showPercent ? '(%)' : ''}{' '} + +
+
0 + ? 'up' + : summary.totalHoldingReturn < 0 + ? 'down' + : '' + } + style={{ + fontSize: '18px', + fontWeight: 700, + fontFamily: 'var(--font-mono)', + cursor: 'pointer', + }} + onClick={() => setShowPercent(!showPercent)} + title="点击切换金额/百分比" + > + {isMasked ? ( + ****** + ) : ( + <> + + {summary.totalHoldingReturn > 0 + ? '+' + : summary.totalHoldingReturn < 0 + ? '-' + : ''} + + {showPercent ? ( + + ) : ( + + )} + + )} +
+
+
+
+
+
+ ); +} diff --git a/app/components/MobileFundTable.jsx b/app/components/MobileFundTable.jsx index aeae01e..aa5f800 100644 --- a/app/components/MobileFundTable.jsx +++ b/app/components/MobileFundTable.jsx @@ -24,9 +24,17 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { throttle } from 'lodash'; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from '@/components/ui/drawer'; import FitText from './FitText'; +import FundCard from './FundCard'; import MobileSettingModal from './MobileSettingModal'; -import { ExitIcon, SettingsIcon, StarIcon } from './Icons'; +import { CloseIcon, ExitIcon, SettingsIcon, StarIcon } from './Icons'; const MOBILE_NON_FROZEN_COLUMN_IDS = [ 'yesterdayChangePercent', @@ -95,6 +103,7 @@ function SortableRow({ row, children, isTableDragging, disabled }) { * @param {boolean} [props.refreshing] - 是否刷新中 * @param {string} [props.sortBy] - 排序方式,'default' 时长按行触发拖拽排序 * @param {(oldIndex: number, newIndex: number) => void} [props.onReorder] - 拖拽排序回调 + * @param {(row: any) => Object} [props.getFundCardProps] - 给定行返回 FundCard 的 props;传入后点击基金名称将用底部弹框展示卡片视图 */ export default function MobileFundTable({ data = [], @@ -110,6 +119,9 @@ export default function MobileFundTable({ onReorder, onCustomSettingsChange, stickyTop = 0, + getFundCardProps, + blockDrawerClose = false, + closeDrawerRef, }) { const sensors = useSensors( useSensor(PointerSensor, { @@ -119,11 +131,19 @@ export default function MobileFundTable({ ); const [activeId, setActiveId] = useState(null); + const ignoreNextDrawerCloseRef = useRef(false); const onToggleFavoriteRef = useRef(onToggleFavorite); const onRemoveFromGroupRef = useRef(onRemoveFromGroup); const onHoldingAmountClickRef = useRef(onHoldingAmountClick); + useEffect(() => { + if (closeDrawerRef) { + closeDrawerRef.current = () => setCardSheetRow(null); + return () => { closeDrawerRef.current = null; }; + } + }, [closeDrawerRef]); + useEffect(() => { onToggleFavoriteRef.current = onToggleFavorite; onRemoveFromGroupRef.current = onRemoveFromGroup; @@ -277,6 +297,7 @@ export default function MobileFundTable({ }; const [settingModalOpen, setSettingModalOpen] = useState(false); + const [cardSheetRow, setCardSheetRow] = useState(null); const tableContainerRef = useRef(null); const portalHeaderRef = useRef(null); const [tableContainerWidth, setTableContainerWidth] = useState(0); @@ -420,8 +441,8 @@ export default function MobileFundTable({ setMobileColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible })); }; - // 移动端名称列:无拖拽把手,长按整行触发排序 - const MobileFundNameCell = ({ info, showFullFundName }) => { + // 移动端名称列:无拖拽把手,长按整行触发排序;点击名称可打开底部卡片弹框(需传入 getFundCardProps) + const MobileFundNameCell = ({ info, showFullFundName, onOpenCardSheet }) => { const original = info.row.original || {}; const code = original.code; const isUpdated = original.isUpdated; @@ -461,7 +482,22 @@ export default function MobileFundTable({
{ + if (onOpenCardSheet) { + e.stopPropagation?.(); + onOpenCardSheet(original); + } + }} + onKeyDown={(e) => { + if (onOpenCardSheet && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onOpenCardSheet(original); + } + }} > {info.getValue() ?? '—'} @@ -547,7 +583,13 @@ export default function MobileFundTable({
), - cell: (info) => , + cell: (info) => ( + setCardSheetRow(row) : undefined} + /> + ), meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName }, }, { @@ -703,7 +745,7 @@ export default function MobileFundTable({ meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit }, }, ], - [currentTab, favorites, refreshing, columnWidthMap, showFullFundName] + [currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps] ); const table = useReactTable({ @@ -953,6 +995,55 @@ export default function MobileFundTable({ /> )} + { + if (!open) { + if (ignoreNextDrawerCloseRef.current) { + ignoreNextDrawerCloseRef.current = false; + return; + } + if (!blockDrawerClose) setCardSheetRow(null); + } + }} + > + { + if (blockDrawerClose) return; + if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) { + ignoreNextDrawerCloseRef.current = true; + return; + } + setCardSheetRow(null); + }} + > + + + 基金详情 + + + + + +
+ {cardSheetRow && getFundCardProps ? ( + + ) : null} +
+
+
+ {!onlyShowHeader && showPortalHeader && ReactDOM.createPortal(renderContent(true), document.body)}
); diff --git a/app/components/MobileSettingModal.jsx b/app/components/MobileSettingModal.jsx index b00648f..4c59b27 100644 --- a/app/components/MobileSettingModal.jsx +++ b/app/components/MobileSettingModal.jsx @@ -1,13 +1,20 @@ 'use client'; import { useEffect, useState } from 'react'; -import { AnimatePresence, motion, Reorder } from 'framer-motion'; -import { createPortal } from 'react-dom'; +import { AnimatePresence, Reorder } from 'framer-motion'; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerClose, +} from '@/components/ui/drawer'; +import { Switch } from '@/components/ui/switch'; import ConfirmModal from './ConfirmModal'; import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons'; /** - * 移动端表格个性化设置弹框(底部抽屉) + * 移动端表格个性化设置弹框(底部抽屉,基于 Drawer 组件) * @param {Object} props * @param {boolean} props.open - 是否打开 * @param {() => void} props.onClose - 关闭回调 @@ -38,226 +45,169 @@ export default function MobileSettingModal({ if (!open) setResetConfirmOpen(false); }, [open]); - useEffect(() => { - if (open) { - const prev = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = prev; - }; - } - }, [open]); - const handleReorder = (newItems) => { const newOrder = newItems.map((item) => item.id); onColumnReorder?.(newOrder); }; - const content = ( - - {open && ( - + { + if (!v) onClose(); + }} + direction="bottom" + > + - e.stopPropagation()} - > -
-
- - 个性化设置 -
- -
+ + + + 个性化设置 + + + + + -
- {onToggleShowFullFundName && ( -
- 展示完整基金名称 - -
- )} -

表头设置

+
+ {onToggleShowFullFundName && (
-

- 拖拽调整列顺序 -

- {(onResetColumnOrder || onResetColumnVisibility) && ( - - )} + 展示完整基金名称 + { + onToggleShowFullFundName?.(!!checked); + }} + title={showFullFundName ? '关闭' : '开启'} + />
- {columns.length === 0 ? ( -
- 暂无可配置列 -
- ) : ( - 表头设置 +
+

+ 拖拽调整列顺序 +

+ {(onResetColumnOrder || onResetColumnVisibility) && ( + - )} - - ))} - - + + )}
- - - )} - {resetConfirmOpen && ( - { - onResetColumnOrder?.(); - onResetColumnVisibility?.(); - setResetConfirmOpen(false); - }} - onCancel={() => setResetConfirmOpen(false)} - confirmText="重置" - /> - )} - - ); + {columns.length === 0 ? ( +
+ 暂无可配置列 +
+ ) : ( + + + {columns.map((item, index) => ( + +
+ +
+ {item.header} + {onToggleColumnVisibility && ( + { + onToggleColumnVisibility(item.id, !!checked); + }} + title={columnVisibility?.[item.id] === false ? '显示' : '隐藏'} + /> + )} +
+ ))} +
+
+ )} +
+ + - if (typeof document === 'undefined') return null; - return createPortal(content, document.body); + + {resetConfirmOpen && ( + { + onResetColumnOrder?.(); + onResetColumnVisibility?.(); + setResetConfirmOpen(false); + }} + onCancel={() => setResetConfirmOpen(false)} + confirmText="重置" + /> + )} + + + ); } diff --git a/app/globals.css b/app/globals.css index 25751f8..df89f68 100644 --- a/app/globals.css +++ b/app/globals.css @@ -43,6 +43,13 @@ --sidebar-accent-foreground: #e5e7eb; --sidebar-border: #1f2937; --sidebar-ring: #22d3ee; + --drawer-overlay: rgba(2, 6, 23, 0.5); + --dialog-overlay: rgba(2, 6, 23, 0.6); + --tabs-list-bg: rgba(255, 255, 255, 0.04); + --tabs-list-border: transparent; + --tabs-trigger-active-bg: rgba(34, 211, 238, 0.12); + --tabs-trigger-active-text: var(--primary); + --switch-thumb: var(--foreground); } /* 亮色主题:ui-ux-pro-max 规范 - 正文 #0F172A、弱化 #475569、玻璃 bg-white/80+、边框可见 */ @@ -84,6 +91,13 @@ --sidebar-accent-foreground: #0f172a; --sidebar-border: #e2e8f0; --sidebar-ring: #0891b2; + --drawer-overlay: rgba(15, 23, 42, 0.25); + --dialog-overlay: rgba(15, 23, 42, 0.35); + --tabs-list-bg: rgba(0, 0, 0, 0.04); + --tabs-list-border: var(--border); + --tabs-trigger-active-bg: rgba(8, 145, 178, 0.15); + --tabs-trigger-active-text: var(--primary); + --switch-thumb: var(--background); } * { @@ -2033,6 +2047,65 @@ input[type="number"] { justify-content: center; } +/* shadcn Drawer:符合项目规范,适配亮/暗主题 */ +.drawer-shadow-bottom { + box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.3); +} +.drawer-shadow-top { + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} +[data-theme="light"] .drawer-shadow-bottom { + box-shadow: 0 -4px 24px rgba(15, 23, 42, 0.12); +} +[data-theme="light"] .drawer-shadow-top { + box-shadow: 0 4px 24px rgba(15, 23, 42, 0.12); +} + +/* shadcn Dialog:符合项目规范(ui-ux-pro-max),适配亮/暗主题,略微玻璃拟态 */ +[data-slot="dialog-content"] { + background: rgba(17, 24, 39, 0.96); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +[data-theme="light"] [data-slot="dialog-content"] { + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.dialog-content-shadow { + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); +} + +[data-theme="light"] .dialog-content-shadow { + box-shadow: 0 4px 24px rgba(15, 23, 42, 0.08); +} + +[data-slot="dialog-content"] [data-slot="dialog-close"] { + cursor: pointer; + color: var(--muted-foreground); + transition: color 0.2s ease, opacity 0.2s ease; +} + +[data-slot="dialog-content"] [data-slot="dialog-close"]:hover { + color: var(--foreground); + opacity: 1; +} + +[data-slot="dialog-content"] [data-slot="dialog-close"]:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +[data-slot="dialog-title"] { + color: var(--foreground); +} + +[data-slot="dialog-description"] { + color: var(--muted-foreground); +} + .mobile-setting-drawer { width: 100%; max-height: 90vh; @@ -3192,6 +3265,26 @@ input[type="number"] { box-shadow: none; } +.button.danger { + background: linear-gradient(180deg, #ef4444, #f87171); + color: #2b0b0b; + border-color: rgba(248, 113, 113, 0.4); +} + +.button.danger:hover { + box-shadow: 0 10px 20px rgba(248, 113, 113, 0.25); +} + +[data-theme="light"] .button.danger { + background: linear-gradient(180deg, #ef4444, #dc2626); + color: #fff; + border-color: rgba(220, 38, 38, 0.4); +} + +[data-theme="light"] .button.danger:hover { + box-shadow: 0 6px 16px rgba(220, 38, 38, 0.25); +} + /* ========== 移动端响应式 ========== */ @media (max-width: 640px) { diff --git a/app/page.jsx b/app/page.jsx index 8d3304c..d1dbea6 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -13,13 +13,11 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import { isNumber, isString, isPlainObject } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import Announcement from "./components/Announcement"; -import { Stat } from "./components/Common"; -import FundTrendChart from "./components/FundTrendChart"; -import FundIntradayChart from "./components/FundIntradayChart"; +import EmptyStateCard from "./components/EmptyStateCard"; +import FundCard from "./components/FundCard"; +import GroupSummary from "./components/GroupSummary"; import { - ChevronIcon, CloseIcon, - ExitIcon, EyeIcon, EyeOffIcon, GridIcon, @@ -32,13 +30,10 @@ import { PlusIcon, SettingsIcon, SortIcon, - StarIcon, SunIcon, - TrashIcon, UpdateIcon, UserIcon, CameraIcon, - SwitchIcon } from "./components/Icons"; import AddFundToGroupModal from "./components/AddFundToGroupModal"; import AddResultModal from "./components/AddResultModal"; @@ -116,214 +111,6 @@ function ScanButton({ onClick, disabled }) { ); } -// 数字滚动组件(初始化时无动画,后续变更再动画) -function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = '', style = {} }) { - const [displayValue, setDisplayValue] = useState(value); - const previousValue = useRef(value); - const isFirstChange = useRef(true); - - useEffect(() => { - if (previousValue.current === value) return; - - // 首次数值变化(包括从 0/默认值变为实际数据)不做动画,直接跳到目标值 - if (isFirstChange.current) { - isFirstChange.current = false; - previousValue.current = value; - setDisplayValue(value); - return; - } - - const start = previousValue.current; - const end = value; - const duration = 400; // 0.4秒动画 - const startTime = performance.now(); - - const animate = (currentTime) => { - const elapsed = currentTime - startTime; - const progress = Math.min(elapsed / duration, 1); - - // easeOutQuart - const ease = 1 - Math.pow(1 - progress, 4); - - const current = start + (end - start) * ease; - setDisplayValue(current); - - if (progress < 1) { - requestAnimationFrame(animate); - } else { - previousValue.current = value; - } - }; - - requestAnimationFrame(animate); - }, [value]); - - return ( - - {prefix}{Math.abs(displayValue).toFixed(decimals)}{suffix} - - ); -} - -function GroupSummary({ funds, holdings, groupName, getProfit, stickyTop }) { - const [showPercent, setShowPercent] = useState(true); - const [isMasked, setIsMasked] = useState(false); - const [isSticky, setIsSticky] = useState(false); - const rowRef = useRef(null); - const [assetSize, setAssetSize] = useState(24); - const [metricSize, setMetricSize] = useState(18); - const [winW, setWinW] = useState(0); - useEffect(() => { - if (typeof window !== 'undefined') { - setWinW(window.innerWidth); - const onR = () => setWinW(window.innerWidth); - window.addEventListener('resize', onR); - return () => window.removeEventListener('resize', onR); - } - }, []); - - const summary = useMemo(() => { - let totalAsset = 0; - let totalProfitToday = 0; - let totalHoldingReturn = 0; - let totalCost = 0; - let hasHolding = false; - let hasAnyTodayData = false; - - funds.forEach(fund => { - const holding = holdings[fund.code]; - const profit = getProfit(fund, holding); - - if (profit) { - hasHolding = true; - totalAsset += profit.amount; - if (profit.profitToday != null) { - // 与卡片展示口径一致:先按“分”四舍五入再汇总,避免浮点误差/逐项舍入差导致不一致 - totalProfitToday += Math.round(profit.profitToday * 100) / 100; - hasAnyTodayData = true; - } - if (profit.profitTotal !== null) { - totalHoldingReturn += profit.profitTotal; - if (holding && isNumber(holding.cost) && isNumber(holding.share)) { - totalCost += holding.cost * holding.share; - } - } - } - }); - - const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0; - - return { totalAsset, totalProfitToday, totalHoldingReturn, hasHolding, returnRate, hasAnyTodayData }; - }, [funds, holdings, getProfit]); - - useLayoutEffect(() => { - const el = rowRef.current; - if (!el) return; - const height = el.clientHeight; - // 使用 80px 作为更严格的阈值,因为 margin/padding 可能导致实际占用更高 - const tooTall = height > 80; - if (tooTall) { - setAssetSize(s => Math.max(16, s - 1)); - setMetricSize(s => Math.max(12, s - 1)); - } else { - // 如果高度正常,尝试适当恢复字体大小,但不要超过初始值 - // 这里的逻辑可以优化:如果当前远小于阈值,可以尝试增大,但为了稳定性,主要处理缩小的场景 - // 或者:如果高度非常小(例如远小于80),可以尝试+1,但要小心死循环 - } - }, [winW, summary.totalAsset, summary.totalProfitToday, summary.totalHoldingReturn, summary.returnRate, showPercent, assetSize, metricSize]); // 添加 assetSize, metricSize 到依赖,确保逐步缩小生效 - - if (!summary.hasHolding) return null; - - return ( -
-
- setIsSticky(!isSticky)} - style={{ - position: 'absolute', - top: 4, - right: 4, - width: 24, - height: 24, - padding: 4, - opacity: 0.6, - zIndex: 10, - color: 'var(--muted)' - }} - > - {isSticky ? : } - -
-
-
-
{groupName}
- -
-
- ¥ - {isMasked ? ( - ****** - ) : ( - - )} -
-
-
-
-
当日收益
-
0 ? 'up' : summary.totalProfitToday < 0 ? 'down' : '') : 'muted'} - style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--font-mono)' }} - > - {isMasked ? ( - ****** - ) : summary.hasAnyTodayData ? ( - <> - {summary.totalProfitToday > 0 ? '+' : summary.totalProfitToday < 0 ? '-' : ''} - - - ) : ( - -- - )} -
-
-
-
持有收益{showPercent ? '(%)' : ''}
-
0 ? 'up' : summary.totalHoldingReturn < 0 ? 'down' : ''} - style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--font-mono)', cursor: 'pointer' }} - onClick={() => setShowPercent(!showPercent)} - title="点击切换金额/百分比" - > - {isMasked ? ( - ****** - ) : ( - <> - {summary.totalHoldingReturn > 0 ? '+' : summary.totalHoldingReturn < 0 ? '-' : ''} - {showPercent ? ( - - ) : ( - - )} - - )} -
-
-
-
-
-
- ); -} - export default function HomePage() { const [funds, setFunds] = useState([]); const [loading, setLoading] = useState(false); @@ -531,6 +318,7 @@ export default function HomePage() { const [isTradingDay, setIsTradingDay] = useState(true); // 默认为交易日,通过接口校正 const tabsRef = useRef(null); const [fundDeleteConfirm, setFundDeleteConfirm] = useState(null); // { code, name } + const fundDetailDrawerCloseRef = useRef(null); // 由 MobileFundTable 注入,用于确认删除时关闭基金详情 Drawer const todayStr = formatDate(); @@ -4129,15 +3917,11 @@ export default function HomePage() {
{displayFunds.length === 0 ? ( -
-
📂
-
{funds.length === 0 ? '尚未添加基金' : '该分组下暂无数据'}
- {currentTab !== 'all' && currentTab !== 'fav' && funds.length > 0 && ( - - )} -
+ setAddFundToGroupOpen(true)} + /> ) : ( <> { if (refreshing) return; @@ -4279,6 +4065,37 @@ export default function HomePage() { setPercentModes((prev) => ({ ...prev, [row.code]: !prev[row.code] })); }} onCustomSettingsChange={triggerCustomSettingsSync} + getFundCardProps={(row) => { + const fund = row?.rawFund || (row ? { code: row.code, name: row.fundName } : null); + if (!fund) return {}; + return { + fund, + todayStr, + currentTab, + favorites, + dcaPlans, + holdings, + percentModes, + valuationSeries, + collapsedCodes, + collapsedTrends, + transactions, + theme, + isTradingDay, + refreshing, + getHoldingProfit, + onRemoveFromGroup: removeFundFromCurrentGroup, + onToggleFavorite: toggleFavorite, + onRemoveFund: requestRemoveFund, + onHoldingClick: (f) => setHoldingModal({ open: true, fund: f }), + onActionClick: (f) => setActionModal({ open: true, fund: f }), + onPercentModeToggle: (code) => + setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })), + onToggleCollapse: toggleCollapse, + onToggleTrendCollapse: toggleTrendCollapse, + layoutMode: 'drawer', + }; + }} /> )} @@ -4293,274 +4110,33 @@ export default function HomePage() { transition={{ duration: 0.2 }} style={{ position: 'relative', overflow: 'hidden' }} > - - <> -
-
- {currentTab !== 'all' && currentTab !== 'fav' ? ( - - ) : ( - - )} -
- - {f.name} - - - #{f.code} - {dcaPlans[f.code]?.enabled === true && } - {f.jzrq === todayStr && } - -
-
- -
-
- {f.noValuation ? '净值日期' : '估值时间'} - {f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')} -
-
- -
-
-
- -
- - {f.noValuation ? ( - // 无估值数据的基金,直接显示净值涨跌幅,不显示估值相关字段 - 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : '—'} - delta={f.zzl} - /> - ) : ( - <> - {(() => { - const hasTodayData = f.jzrq === todayStr; - let isYesterdayChange = false; - let isPreviousTradingDay = false; - if (!hasTodayData && isString(f.jzrq)) { - const today = toTz(todayStr).startOf('day'); - const jzDate = toTz(f.jzrq).startOf('day'); - const yesterday = today.clone().subtract(1, 'day'); - if (jzDate.isSame(yesterday, 'day')) { - isYesterdayChange = true; - } else if (jzDate.isBefore(yesterday, 'day')) { - isPreviousTradingDay = true; - } - } - const shouldHideChange = isTradingDay && !hasTodayData && !isYesterdayChange && !isPreviousTradingDay; - - if (shouldHideChange) return null; - - // 不再区分“上一交易日涨跌幅”名称,统一使用“昨日涨幅” - const changeLabel = hasTodayData ? '涨跌幅' : '昨日涨幅'; - return ( - 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : ''} - delta={f.zzl} - /> - ); - })()} - 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')} /> - 0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (isNumber(f.gszzl) ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')} - delta={f.estPricedCoverage > 0.05 ? f.estGszzl : (Number(f.gszzl) || 0)} - /> - - )} -
- -
- {(() => { - const holding = holdings[f.code]; - const profit = getHoldingProfit(f, holding); - - if (!profit) { - return ( -
- 持仓金额 -
setHoldingModal({ open: true, fund: f })} - > - 未设置 -
-
- ); - } - - return ( - <> -
setActionModal({ open: true, fund: f })} - > - - 持仓金额 - - ¥{profit.amount.toFixed(2)} -
-
- 当日收益 - 0 ? 'up' : profit.profitToday < 0 ? 'down' : '') : 'muted'}`}> - {profit.profitToday != null - ? `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}` - : '--'} - -
- {profit.profitTotal !== null && ( -
{ - e.stopPropagation(); - setPercentModes(prev => ({ ...prev, [f.code]: !prev[f.code] })); - }} - style={{ cursor: 'pointer', flexDirection: 'column', gap: 4 }} - title="点击切换金额/百分比" - > - 持有收益{percentModes[f.code] ? '(%)' : ''} - 0 ? 'up' : profit.profitTotal < 0 ? 'down' : ''}`}> - {profit.profitTotal > 0 ? '+' : profit.profitTotal < 0 ? '-' : ''} - {percentModes[f.code] - ? `${Math.abs((holding.cost * holding.share) ? (profit.profitTotal / (holding.cost * holding.share)) * 100 : 0).toFixed(2)}%` - : `¥${Math.abs(profit.profitTotal).toFixed(2)}` - } - -
- )} - - ); - })()} -
- - {f.estPricedCoverage > 0.05 && ( -
- 基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算 -
- )} - {(() => { - const showIntraday = Array.isArray(valuationSeries[f.code]) && valuationSeries[f.code].length >= 2; - if (!showIntraday) return null; - - // 如果今日日期大于估值日期,说明是历史估值,不显示分时图 - if (f.gztime && toTz(todayStr).startOf('day').isAfter(toTz(f.gztime).startOf('day'))) { - return null; - } - - // 如果 jzrq 等于估值日期或在此之前(意味着净值已经更新到了估值日期,或者是历史数据),则隐藏实时估值分时 - if (f.jzrq && f.gztime && toTz(f.jzrq).startOf('day').isSameOrAfter(toTz(f.gztime).startOf('day'))) { - return null; - } - - return ( - - ); - })()} - {f.holdingsIsLastQuarter && Array.isArray(f.holdings) && f.holdings.length > 0 && ( - <> -
toggleCollapse(f.code)} - > -
-
- 前10重仓股票 - -
- 涨跌幅 / 占比 -
-
- - {!collapsedCodes.has(f.code) && ( - -
- {f.holdings.map((h, idx) => ( -
- {h.name} -
- {isNumber(h.change) && ( - 0 ? 'up' : h.change < 0 ? 'down' : ''}`} style={{ marginRight: 8 }}> - {h.change > 0 ? '+' : ''}{h.change.toFixed(2)}% - - )} - {h.weight} -
-
- ))} -
-
- )} -
- - )} - toggleTrendCollapse(f.code)} - transactions={transactions[f.code] || []} - theme={theme} - /> - -
+ setHoldingModal({ open: true, fund })} + onActionClick={(fund) => setActionModal({ open: true, fund })} + onPercentModeToggle={(code) => + setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })) + } + onToggleCollapse={toggleCollapse} + onToggleTrendCollapse={toggleTrendCollapse} + />
))}
@@ -4579,6 +4155,7 @@ export default function HomePage() { message={`基金 "${fundDeleteConfirm.name}" 存在持仓记录。删除后将移除该基金及其持仓数据,是否继续?`} confirmText="确定删除" onConfirm={() => { + fundDetailDrawerCloseRef.current?.(); removeFund(fundDeleteConfirm.code); setFundDeleteConfirm(null); }} diff --git a/components/ui/dialog.jsx b/components/ui/dialog.jsx new file mode 100644 index 0000000..a99018f --- /dev/null +++ b/components/ui/dialog.jsx @@ -0,0 +1,148 @@ +"use client" + +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}) { + return ; +} + +function DialogTrigger({ + ...props +}) { + return ; +} + +function DialogPortal({ + ...props +}) { + return ; +} + +function DialogClose({ + ...props +}) { + return ; +} + +function DialogOverlay({ + className, + ...props +}) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ + className, + ...props +}) { + return ( +
+ ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ); +} + +function DialogTitle({ + className, + ...props +}) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/components/ui/drawer.jsx b/components/ui/drawer.jsx new file mode 100644 index 0000000..aa95895 --- /dev/null +++ b/components/ui/drawer.jsx @@ -0,0 +1,222 @@ +"use client" + +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +function parseVhToPx(vhStr) { + if (typeof vhStr === "number") return vhStr + const match = String(vhStr).match(/^([\d.]+)\s*vh$/) + if (!match) return null + return (window.innerHeight * Number(match[1])) / 100 +} + +function Drawer({ + ...props +}) { + return ; +} + +function DrawerTrigger({ + ...props +}) { + return ; +} + +function DrawerPortal({ + ...props +}) { + return ; +} + +function DrawerClose({ + ...props +}) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}) { + return ( + + ); +} + +function DrawerContent({ + className, + children, + defaultHeight = "77vh", + minHeight = "20vh", + maxHeight = "90vh", + ...props +}) { + const [heightPx, setHeightPx] = React.useState(() => + typeof window !== "undefined" ? parseVhToPx(defaultHeight) : null + ); + const [isDragging, setIsDragging] = React.useState(false); + const dragRef = React.useRef({ startY: 0, startHeight: 0 }); + + const minPx = React.useMemo(() => parseVhToPx(minHeight), [minHeight]); + const maxPx = React.useMemo(() => parseVhToPx(maxHeight), [maxHeight]); + + React.useEffect(() => { + const px = parseVhToPx(defaultHeight); + if (px != null) setHeightPx(px); + }, [defaultHeight]); + + React.useEffect(() => { + const sync = () => { + const max = parseVhToPx(maxHeight); + const min = parseVhToPx(minHeight); + setHeightPx((prev) => { + if (prev == null) return parseVhToPx(defaultHeight); + const clamped = Math.min(prev, max ?? prev); + return Math.max(clamped, min ?? clamped); + }); + }; + window.addEventListener("resize", sync); + return () => window.removeEventListener("resize", sync); + }, [defaultHeight, minHeight, maxHeight]); + + const handlePointerDown = React.useCallback( + (e) => { + e.preventDefault(); + setIsDragging(true); + dragRef.current = { startY: e.clientY ?? e.touches?.[0]?.clientY, startHeight: heightPx ?? parseVhToPx(defaultHeight) ?? 0 }; + }, + [heightPx, defaultHeight] + ); + + React.useEffect(() => { + if (!isDragging) return; + const move = (e) => { + const clientY = e.clientY ?? e.touches?.[0]?.clientY; + const { startY, startHeight } = dragRef.current; + const delta = startY - clientY; + const next = Math.min(maxPx ?? Infinity, Math.max(minPx ?? 0, startHeight + delta)); + setHeightPx(next); + }; + const up = () => setIsDragging(false); + document.addEventListener("mousemove", move, { passive: true }); + document.addEventListener("mouseup", up); + document.addEventListener("touchmove", move, { passive: true }); + document.addEventListener("touchend", up); + return () => { + document.removeEventListener("mousemove", move); + document.removeEventListener("mouseup", up); + document.removeEventListener("touchmove", move); + document.removeEventListener("touchend", up); + }; + }, [isDragging, minPx, maxPx]); + + const contentStyle = React.useMemo(() => { + if (heightPx == null) return undefined; + return { height: `${heightPx}px`, maxHeight: maxPx != null ? `${maxPx}px` : undefined }; + }, [heightPx, maxPx]); + + return ( + + + +