'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { flexRender, getCoreRowModel, useReactTable, } from '@tanstack/react-table'; import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, } from '@dnd-kit/core'; import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers'; import { SortableContext, verticalListSortingStrategy, useSortable, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import FitText from './FitText'; import MobileSettingModal from './MobileSettingModal'; import { ExitIcon, SettingsIcon, StarIcon } from './Icons'; const MOBILE_NON_FROZEN_COLUMN_IDS = [ 'yesterdayChangePercent', 'estimateChangePercent', 'todayProfit', 'holdingProfit', 'latestNav', 'estimateNav', ]; const MOBILE_COLUMN_HEADERS = { latestNav: '最新净值', estimateNav: '估算净值', yesterdayChangePercent: '昨日涨跌幅', estimateChangePercent: '估值涨跌幅', todayProfit: '当日收益', holdingProfit: '持有收益', }; function SortableRow({ row, children, isTableDragging, disabled }) { const { attributes, listeners, transform, transition, setNodeRef, setActivatorNodeRef, isDragging, } = useSortable({ id: row.original.code, disabled }); const style = { transform: CSS.Transform.toString(transform), transition, ...(isDragging ? { position: 'relative', zIndex: 9999, opacity: 0.8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' } : {}), }; return ( {typeof children === 'function' ? children(setActivatorNodeRef, listeners) : children} ); } /** * 移动端基金列表表格组件(基于 @tanstack/react-table,与 PcFundTable 相同数据结构) * * @param {Object} props - 与 PcFundTable 一致 * @param {Array} props.data - 表格数据(与 pcFundTableData 同结构) * @param {(row: any) => void} [props.onRemoveFund] - 删除基金 * @param {string} [props.currentTab] - 当前分组 * @param {Set} [props.favorites] - 自选集合 * @param {(row: any) => void} [props.onToggleFavorite] - 添加/取消自选 * @param {(row: any) => void} [props.onRemoveFromGroup] - 从当前分组移除 * @param {(row: any, meta: { hasHolding: boolean }) => void} [props.onHoldingAmountClick] - 点击持仓金额 * @param {(row: any) => void} [props.onHoldingProfitClick] - 点击持有收益 * @param {boolean} [props.refreshing] - 是否刷新中 * @param {string} [props.sortBy] - 排序方式,'default' 时长按行触发拖拽排序 * @param {(oldIndex: number, newIndex: number) => void} [props.onReorder] - 拖拽排序回调 */ export default function MobileFundTable({ data = [], onRemoveFund, currentTab, favorites = new Set(), onToggleFavorite, onRemoveFromGroup, onHoldingAmountClick, onHoldingProfitClick, refreshing = false, sortBy = 'default', onReorder, onCustomSettingsChange, }) { const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { delay: 400, tolerance: 5 }, }), useSensor(KeyboardSensor) ); const [activeId, setActiveId] = useState(null); const onToggleFavoriteRef = useRef(onToggleFavorite); const onRemoveFromGroupRef = useRef(onRemoveFromGroup); const onHoldingAmountClickRef = useRef(onHoldingAmountClick); const onHoldingProfitClickRef = useRef(onHoldingProfitClick); useEffect(() => { onToggleFavoriteRef.current = onToggleFavorite; onRemoveFromGroupRef.current = onRemoveFromGroup; onHoldingAmountClickRef.current = onHoldingAmountClick; onHoldingProfitClickRef.current = onHoldingProfitClick; }, [ onToggleFavorite, onRemoveFromGroup, onHoldingAmountClick, onHoldingProfitClick, ]); const handleDragStart = (e) => setActiveId(e.active.id); const handleDragCancel = () => setActiveId(null); const handleDragEnd = (e) => { const { active, over } = e; if (active && over && active.id !== over.id && onReorder) { const oldIndex = data.findIndex((item) => item.code === active.id); const newIndex = data.findIndex((item) => item.code === over.id); if (oldIndex !== -1 && newIndex !== -1) onReorder(oldIndex, newIndex); } setActiveId(null); }; const getStoredMobileColumnOrder = () => { if (typeof window === 'undefined') return null; try { const raw = window.localStorage.getItem('customSettings'); if (!raw) return null; const parsed = JSON.parse(raw); const order = parsed?.mobileTableColumnOrder; if (!Array.isArray(order) || order.length === 0) return null; const valid = order.filter((id) => MOBILE_NON_FROZEN_COLUMN_IDS.includes(id)); const missing = MOBILE_NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id)); return [...valid, ...missing]; } catch { return null; } }; const persistMobileColumnOrder = (nextOrder) => { if (typeof window === 'undefined') return; try { const raw = window.localStorage.getItem('customSettings'); const parsed = raw ? JSON.parse(raw) : {}; const nextSettings = parsed && typeof parsed === 'object' ? { ...parsed, mobileTableColumnOrder: nextOrder } : { mobileTableColumnOrder: nextOrder }; window.localStorage.setItem('customSettings', JSON.stringify(nextSettings)); onCustomSettingsChange?.(); } catch {} }; const getStoredMobileColumnVisibility = () => { if (typeof window === 'undefined') return null; try { const raw = window.localStorage.getItem('customSettings'); if (!raw) return null; const parsed = JSON.parse(raw); const visibility = parsed?.mobileTableColumnVisibility; if (!visibility || typeof visibility !== 'object') return null; const normalized = {}; MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { const value = visibility[id]; if (typeof value === 'boolean') normalized[id] = value; }); return Object.keys(normalized).length ? normalized : null; } catch { return null; } }; const persistMobileColumnVisibility = (nextVisibility) => { if (typeof window === 'undefined') return; try { const raw = window.localStorage.getItem('customSettings'); const parsed = raw ? JSON.parse(raw) : {}; const nextSettings = parsed && typeof parsed === 'object' ? { ...parsed, mobileTableColumnVisibility: nextVisibility } : { mobileTableColumnVisibility: nextVisibility }; window.localStorage.setItem('customSettings', JSON.stringify(nextSettings)); onCustomSettingsChange?.(); } catch {} }; const [mobileColumnOrder, setMobileColumnOrder] = useState( () => getStoredMobileColumnOrder() ?? [...MOBILE_NON_FROZEN_COLUMN_IDS] ); const [mobileColumnVisibility, setMobileColumnVisibility] = useState(() => { const stored = getStoredMobileColumnVisibility(); if (stored) return stored; const allVisible = {}; MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; }); return allVisible; }); const [settingModalOpen, setSettingModalOpen] = useState(false); const tableContainerRef = useRef(null); const [tableContainerWidth, setTableContainerWidth] = useState(0); useEffect(() => { const el = tableContainerRef.current; if (!el) return; const updateWidth = () => setTableContainerWidth(el.clientWidth || 0); updateWidth(); const ro = new ResizeObserver(updateWidth); ro.observe(el); return () => ro.disconnect(); }, []); const NAME_CELL_WIDTH = 140; const GAP = 12; const LAST_COLUMN_EXTRA = 12; const FALLBACK_WIDTHS = { fundName: 140, latestNav: 64, estimateNav: 64, yesterdayChangePercent: 72, estimateChangePercent: 80, todayProfit: 80, holdingProfit: 80, }; const columnWidthMap = useMemo(() => { const visibleNonNameIds = mobileColumnOrder.filter((id) => mobileColumnVisibility[id] !== false); const nonNameCount = visibleNonNameIds.length; if (tableContainerWidth > 0 && nonNameCount > 0) { const gapTotal = nonNameCount >= 3 ? 3 * GAP : (nonNameCount) * GAP; const remaining = tableContainerWidth - NAME_CELL_WIDTH - gapTotal - LAST_COLUMN_EXTRA; const divisor = nonNameCount >= 3 ? 3 : nonNameCount; const otherColumnWidth = Math.max(48, Math.floor(remaining / divisor)); const map = { fundName: NAME_CELL_WIDTH }; MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { map[id] = otherColumnWidth; }); return map; } return { ...FALLBACK_WIDTHS }; }, [tableContainerWidth, mobileColumnOrder, mobileColumnVisibility]); const handleResetMobileColumnOrder = () => { const defaultOrder = [...MOBILE_NON_FROZEN_COLUMN_IDS]; setMobileColumnOrder(defaultOrder); persistMobileColumnOrder(defaultOrder); }; const handleResetMobileColumnVisibility = () => { const allVisible = {}; MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; }); setMobileColumnVisibility(allVisible); persistMobileColumnVisibility(allVisible); }; const handleToggleMobileColumnVisibility = (columnId, visible) => { setMobileColumnVisibility((prev = {}) => { const next = { ...prev, [columnId]: visible }; persistMobileColumnVisibility(next); return next; }); }; // 移动端名称列:无拖拽把手,长按整行触发排序 const MobileFundNameCell = ({ info }) => { const original = info.row.original || {}; const code = original.code; const isUpdated = original.isUpdated; const hasHoldingAmount = original.holdingAmountValue != null; const holdingAmountDisplay = hasHoldingAmount ? (original.holdingAmount ?? '—') : null; const isFavorites = favorites?.has?.(code); const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav'; return (
{isGroupTab ? ( ) : ( )}
{info.getValue() ?? '—'} {holdingAmountDisplay ? ( { e.stopPropagation?.(); onHoldingAmountClickRef.current?.(original, { hasHolding: true }); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onHoldingAmountClickRef.current?.(original, { hasHolding: true }); } }} > {holdingAmountDisplay} {isUpdated && } ) : code ? ( { e.stopPropagation?.(); onHoldingAmountClickRef.current?.(original, { hasHolding: false }); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onHoldingAmountClickRef.current?.(original, { hasHolding: false }); } }} > #{code} {isUpdated && } ) : null}
); }; const columns = useMemo( () => [ { accessorKey: 'fundName', header: () => (
基金名称
), cell: (info) => , meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName }, }, { accessorKey: 'latestNav', header: '最新净值', cell: (info) => ( {info.getValue() ?? '—'} ), meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.latestNav }, }, { accessorKey: 'estimateNav', header: '估算净值', cell: (info) => ( {info.getValue() ?? '—'} ), meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.estimateNav }, }, { accessorKey: 'yesterdayChangePercent', header: '昨日涨跌幅', cell: (info) => { const original = info.row.original || {}; const value = original.yesterdayChangeValue; const date = original.yesterdayDate ?? '-'; const cls = value > 0 ? 'up' : value < 0 ? 'down' : ''; return (
{info.getValue() ?? '—'} {date}
); }, meta: { align: 'right', cellClassName: 'change-cell', width: columnWidthMap.yesterdayChangePercent }, }, { accessorKey: 'estimateChangePercent', header: '估值涨跌幅', cell: (info) => { const original = info.row.original || {}; const value = original.estimateChangeValue; const isMuted = original.estimateChangeMuted; const time = original.estimateTime ?? '-'; const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : ''; return (
{info.getValue() ?? '—'} {time}
); }, meta: { align: 'right', cellClassName: 'est-change-cell', width: columnWidthMap.estimateChangePercent }, }, { accessorKey: 'todayProfit', header: '当日收益', cell: (info) => { const original = info.row.original || {}; const value = original.todayProfitValue; const hasProfit = value != null; const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted'; return ( {hasProfit ? (info.getValue() ?? '') : ''} ); }, meta: { align: 'right', cellClassName: 'profit-cell', width: columnWidthMap.todayProfit }, }, { accessorKey: 'holdingProfit', header: '持有收益', cell: (info) => { const original = info.row.original || {}; const value = original.holdingProfitValue; const hasTotal = value != null; const cls = hasTotal ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted'; return (
{ if (!hasTotal) return; e.stopPropagation?.(); onHoldingProfitClickRef.current?.(original); }} > {hasTotal ? (info.getValue() ?? '') : ''}
); }, meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit }, }, ], [currentTab, favorites, refreshing, columnWidthMap] ); const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), state: { columnOrder: ['fundName', ...mobileColumnOrder], columnVisibility: { fundName: true, ...mobileColumnVisibility }, }, onColumnOrderChange: (updater) => { const next = typeof updater === 'function' ? updater(['fundName', ...mobileColumnOrder]) : updater; const newNonFrozen = next.filter((id) => id !== 'fundName'); if (newNonFrozen.length) { setMobileColumnOrder(newNonFrozen); persistMobileColumnOrder(newNonFrozen); } }, onColumnVisibilityChange: (updater) => { const next = typeof updater === 'function' ? updater({ fundName: true, ...mobileColumnVisibility }) : updater; const rest = { ...next }; delete rest.fundName; setMobileColumnVisibility(rest); persistMobileColumnVisibility(rest); }, initialState: { columnPinning: { left: ['fundName'], }, }, defaultColumn: { cell: (info) => info.getValue() ?? '—', }, }); const headerGroup = table.getHeaderGroups()[0]; const snapPositionsRef = useRef([]); const scrollEndTimerRef = useRef(null); useEffect(() => { if (!headerGroup?.headers?.length) { snapPositionsRef.current = []; return; } const gap = 12; const widths = headerGroup.headers.map((h) => h.column.columnDef.meta?.width ?? 80); if (widths.length > 0) widths[widths.length - 1] += LAST_COLUMN_EXTRA; const positions = [0]; let acc = 0; // 从第二列开始累加,因为第一列是固定的,滚动是为了让后续列贴合到第一列右侧 // 累加的是"被滚出去"的非固定列的宽度 for (let i = 1; i < widths.length - 1; i++) { acc += widths[i] + gap; positions.push(acc); } snapPositionsRef.current = positions; }, [headerGroup?.headers?.length, columnWidthMap, mobileColumnOrder]); useEffect(() => { const el = tableContainerRef.current; if (!el || snapPositionsRef.current.length === 0) return; const snapToNearest = () => { const positions = snapPositionsRef.current; if (positions.length === 0) return; const scrollLeft = el.scrollLeft; const maxScroll = el.scrollWidth - el.clientWidth; if (maxScroll <= 0) return; const nearest = positions.reduce((prev, curr) => Math.abs(curr - scrollLeft) < Math.abs(prev - scrollLeft) ? curr : prev ); const clamped = Math.max(0, Math.min(maxScroll, nearest)); if (Math.abs(clamped - scrollLeft) > 2) { el.scrollTo({ left: clamped, behavior: 'smooth' }); } }; const handleScroll = () => { if (scrollEndTimerRef.current) clearTimeout(scrollEndTimerRef.current); scrollEndTimerRef.current = setTimeout(snapToNearest, 120); }; el.addEventListener('scroll', handleScroll, { passive: true }); return () => { el.removeEventListener('scroll', handleScroll); if (scrollEndTimerRef.current) clearTimeout(scrollEndTimerRef.current); }; }, []); const mobileGridLayout = (() => { if (!headerGroup?.headers?.length) return { gridTemplateColumns: '', minWidth: undefined }; const gap = 12; const widths = headerGroup.headers.map((h) => h.column.columnDef.meta?.width ?? 80); if (widths.length > 0) widths[widths.length - 1] += LAST_COLUMN_EXTRA; return { gridTemplateColumns: widths.map((w) => `${w}px`).join(' '), minWidth: widths.reduce((a, b) => a + b, 0) + (widths.length - 1) * gap, }; })(); const getPinClass = (columnId, isHeader) => { if (columnId === 'fundName') return isHeader ? 'table-header-cell-pin-left' : 'table-cell-pin-left'; return ''; }; const getAlignClass = (columnId) => { if (columnId === 'fundName') return ''; if (['latestNav', 'estimateNav', 'yesterdayChangePercent', 'estimateChangePercent', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right'; return 'text-right'; }; return (
{headerGroup && (
{headerGroup.headers.map((header, headerIndex) => { const columnId = header.column.id; const pinClass = getPinClass(columnId, true); const alignClass = getAlignClass(columnId); const isLastColumn = headerIndex === headerGroup.headers.length - 1; return (
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
); })}
)} item.code)} strategy={verticalListSortingStrategy} > {table.getRowModel().rows.map((row) => ( {(setActivatorNodeRef, listeners) => (
{row.getVisibleCells().map((cell, cellIndex) => { const columnId = cell.column.id; const pinClass = getPinClass(columnId, false); const alignClass = getAlignClass(columnId); const cellClassName = cell.column.columnDef.meta?.cellClassName || ''; const isLastColumn = cellIndex === row.getVisibleCells().length - 1; return (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
); })}
)}
))}
{table.getRowModel().rows.length === 0 && (
暂无数据
)} setSettingModalOpen(false)} columns={mobileColumnOrder.map((id) => ({ id, header: MOBILE_COLUMN_HEADERS[id] ?? id }))} columnVisibility={mobileColumnVisibility} onColumnReorder={(newOrder) => { setMobileColumnOrder(newOrder); persistMobileColumnOrder(newOrder); }} onToggleColumnVisibility={handleToggleMobileColumnVisibility} onResetColumnOrder={handleResetMobileColumnOrder} onResetColumnVisibility={handleResetMobileColumnVisibility} />
); }