Files
real-time-fund/app/components/PcFundTable.jsx
2026-02-27 08:18:53 +08:00

604 lines
20 KiB
JavaScript
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.
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { ExitIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
/**
* PC 端基金列表表格组件(基于 @tanstack/react-table
*
* @param {Object} props
* @param {Array<Object>} props.data - 表格数据
* 每一行推荐结构(字段命名与 page.jsx 中的数据一致):
* {
* fundName: string; // 基金名称
* code?: string; // 基金代码(可选,只用于展示在名称下方)
* navOrEstimate: string|number; // 净值/估值
* yesterdayChangePercent: string|number; // 昨日涨跌幅
* estimateChangePercent: string|number; // 估值涨跌幅
* holdingAmount: string|number; // 持仓金额
* todayProfit: string|number; // 当日收益
* holdingProfit: string|number; // 持有收益
* }
* @param {(row: any) => void} [props.onRemoveFund] - 删除基金的回调
* @param {string} [props.currentTab] - 当前分组
* @param {Set<string>} [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] - 是否处于刷新状态(控制删除按钮禁用态)
*/
export default function PcFundTable({
data = [],
onRemoveFund,
currentTab,
favorites = new Set(),
onToggleFavorite,
onRemoveFromGroup,
onHoldingAmountClick,
onHoldingProfitClick,
refreshing = false,
}) {
const getStoredColumnSizing = () => {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem('customSettings');
if (!raw) return {};
const parsed = JSON.parse(raw);
const sizing = parsed?.pcTableColumns;
if (!sizing || typeof sizing !== 'object') return {};
return Object.fromEntries(
Object.entries(sizing).filter(([, value]) => Number.isFinite(value)),
);
} catch {
return {};
}
};
const persistColumnSizing = (nextSizing) => {
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, pcTableColumns: nextSizing }
: { pcTableColumns: nextSizing };
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
} catch { }
};
const [columnSizing, setColumnSizing] = useState(() => {
const stored = getStoredColumnSizing();
if (stored.actions) {
const { actions, ...rest } = stored;
return rest;
}
return stored;
});
const onRemoveFundRef = useRef(onRemoveFund);
const onToggleFavoriteRef = useRef(onToggleFavorite);
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
const onHoldingProfitClickRef = useRef(onHoldingProfitClick);
useEffect(() => {
onRemoveFundRef.current = onRemoveFund;
onToggleFavoriteRef.current = onToggleFavorite;
onRemoveFromGroupRef.current = onRemoveFromGroup;
onHoldingAmountClickRef.current = onHoldingAmountClick;
onHoldingProfitClickRef.current = onHoldingProfitClick;
}, [
onRemoveFund,
onToggleFavorite,
onRemoveFromGroup,
onHoldingAmountClick,
onHoldingProfitClick,
]);
const columns = useMemo(
() => [
{
accessorKey: 'fundName',
header: '基金名称',
size: 240,
minSize: 100,
enablePinning: true,
cell: (info) => {
const original = info.row.original || {};
const code = original.code;
const isUpdated = original.isUpdated;
const isFavorites = favorites?.has?.(code);
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
return (
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isGroupTab ? (
<button
className="icon-button fav-button"
onClick={(e) => {
e.stopPropagation?.();
onRemoveFromGroupRef.current?.(original);
}}
title="从当前分组移除"
>
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
</button>
) : (
<button
className={`icon-button fav-button ${isFavorites ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation?.();
onToggleFavoriteRef.current?.(original);
}}
title={isFavorites ? '取消自选' : '添加自选'}
>
<StarIcon width="18" height="18" filled={isFavorites} />
</button>
)}
<div className="title-text">
<span
className={`name-text ${isUpdated ? 'updated' : ''}`}
title={isUpdated ? '今日净值已更新' : ''}
>
{info.getValue() ?? '—'}
</span>
{code ? <span className="muted code-text">#{code}</span> : null}
</div>
</div>
);
},
meta: {
align: 'left',
cellClassName: 'name-cell',
},
},
{
accessorKey: 'navOrEstimate',
header: '净值/估值',
size: 100,
minSize: 80,
cell: (info) => (
<span style={{ fontWeight: 700 }}>{info.getValue() ?? '—'}</span>
),
meta: {
align: 'right',
cellClassName: 'value-cell',
},
},
{
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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
<span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'}
</span>
<span className="muted" style={{ fontSize: '12px' }}>
{date}
</span>
</div>
);
},
meta: {
align: 'right',
cellClassName: 'change-cell',
},
},
{
accessorKey: 'estimateChangePercent',
header: '估值涨跌幅',
size: 100,
minSize: 80,
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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
<span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'}
</span>
<span className="muted" style={{ fontSize: '12px' }}>
{time}
</span>
</div>
);
},
meta: {
align: 'right',
cellClassName: 'est-change-cell',
},
},
{
accessorKey: 'holdingAmount',
header: '持仓金额',
cell: (info) => {
const original = info.row.original || {};
if (original.holdingAmountValue == null) {
return (
<div
role="button"
tabIndex={0}
className="muted"
title="设置持仓"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '12px', cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation?.();
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
}
}}
>
未设置 <SettingsIcon width="12" height="12" />
</div>
);
}
return (
<div
title="点击设置持仓"
style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation?.();
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
}}
>
<span style={{ fontWeight: 700, marginRight: 6 }}>{info.getValue() ?? '—'}</span>
<button
className="icon-button no-hover"
onClick={(e) => {
e.stopPropagation?.();
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
}}
title="编辑持仓"
style={{ border: 'none', width: '28px', height: '28px', marginLeft: -6 }}
>
<SettingsIcon width="14" height="14" />
</button>
</div>
);
},
meta: {
align: 'right',
cellClassName: 'holding-amount-cell',
},
},
{
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 (
<span className={cls} style={{ fontWeight: 700 }}>
{hasProfit ? (info.getValue() ?? '') : ''}
</span>
);
},
meta: {
align: 'right',
cellClassName: 'profit-cell',
},
},
{
accessorKey: 'holdingProfit',
header: '持有收益',
size: 140,
minSize: 100,
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 (
<div
title="点击切换金额/百分比"
style={{ cursor: hasTotal ? 'pointer' : 'default' }}
onClick={(e) => {
if (!hasTotal) return;
e.stopPropagation?.();
onHoldingProfitClickRef.current?.(original);
}}
>
<span className={cls} style={{ fontWeight: 700 }}>
{hasTotal ? (info.getValue() ?? '') : ''}
</span>
</div>
);
},
meta: {
align: 'right',
cellClassName: 'holding-cell',
},
},
{
id: 'actions',
header: '操作',
size: 80,
minSize: 80,
maxSize: 80,
enableResizing: false,
enablePinning: true,
meta: {
align: 'center',
isAction: true,
cellClassName: 'action-cell',
},
cell: (info) => {
const original = info.row.original || {};
const handleClick = (e) => {
e.stopPropagation?.();
if (refreshing) return;
onRemoveFundRef.current?.(original);
};
return (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
className="icon-button danger"
onClick={handleClick}
title="删除"
disabled={refreshing}
style={{
width: '28px',
height: '28px',
opacity: refreshing ? 0.6 : 1,
cursor: refreshing ? 'not-allowed' : 'pointer',
}}
>
<TrashIcon width="14" height="14" />
</button>
</div>
);
},
},
],
[currentTab, favorites, refreshing],
);
const table = useReactTable({
data,
columns,
enableColumnPinning: true,
enableColumnResizing: true,
columnResizeMode: 'onChange',
onColumnSizingChange: (updater) => {
setColumnSizing((prev) => {
const next = typeof updater === 'function' ? updater(prev) : updater;
const { actions, ...rest } = next || {};
persistColumnSizing(rest || {});
return rest || {};
});
},
state: {
columnSizing,
},
initialState: {
columnPinning: {
left: ['fundName'],
right: ['actions'],
},
},
getCoreRowModel: getCoreRowModel(),
defaultColumn: {
cell: (info) => info.getValue() ?? '—',
},
});
const headerGroup = table.getHeaderGroups()[0];
const getCommonPinningStyles = (column, isHeader) => {
const isPinned = column.getIsPinned();
const isNameColumn =
column.id === 'fundName' || column.columnDef?.accessorKey === 'fundName';
const style = {
width: `${column.getSize()}px`,
};
if (!isPinned) return style;
const isLeft = isPinned === 'left';
const isRight = isPinned === 'right';
return {
...style,
position: 'sticky',
left: isLeft ? `${column.getStart('left')}px` : undefined,
right: isRight ? `${column.getAfter('right')}px` : undefined,
zIndex: isHeader ? 11 : 10,
backgroundColor: isHeader ? '#2a394b' : 'var(--row-bg)',
boxShadow: 'none',
textAlign: isNameColumn ? 'left' : 'center',
justifyContent: isNameColumn ? 'flex-start' : 'center',
};
};
return (
<>
<style>{`
.table-row-scroll {
--row-bg: var(--bg);
background-color: var(--row-bg);
}
.table-row-scroll:hover {
--row-bg: #2a394b;
}
/* 覆盖 grid 布局为 flex 以支持动态列宽 */
.table-header-row-scroll,
.table-row-scroll {
display: flex !important;
width: fit-content !important;
min-width: 100%;
gap: 0 !important; /* Reset gap because we control width explicitly */
}
.table-header-cell,
.table-cell {
flex-shrink: 0;
box-sizing: border-box;
padding-left: 8px;
padding-right: 8px;
position: relative; /* For resizer */
}
/* 拖拽把手样式 */
.resizer {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 8px;
background: transparent;
cursor: col-resize;
user-select: none;
touch-action: none;
z-index: 20;
}
.resizer::after {
content: '';
position: absolute;
right: 3px;
top: 12%;
bottom: 12%;
width: 2px;
background: var(--border);
opacity: 0.35;
transition: opacity 0.2s, background-color 0.2s, box-shadow 0.2s;
}
.resizer:hover::after {
opacity: 1;
background: var(--primary);
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.2);
}
.table-header-cell:hover .resizer::after {
opacity: 0.75;
}
.resizer.disabled {
cursor: default;
background: transparent;
pointer-events: none;
}
.resizer.disabled::after {
opacity: 0;
}
`}</style>
{/* 表头 */}
{headerGroup && (
<div className="table-header-row table-header-row-scroll">
{headerGroup.headers.map((header) => {
const style = getCommonPinningStyles(header.column, true);
const isNameColumn =
header.column.id === 'fundName' ||
header.column.columnDef?.accessorKey === 'fundName';
const align = isNameColumn ? '' : 'text-center';
return (
<div
key={header.id}
className={`table-header-cell ${align}`}
style={style}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
<div
onMouseDown={header.column.getCanResize() ? header.getResizeHandler() : undefined}
onTouchStart={header.column.getCanResize() ? header.getResizeHandler() : undefined}
className={`resizer ${
header.column.getIsResizing() ? 'isResizing' : ''
} ${header.column.getCanResize() ? '' : 'disabled'}`}
/>
</div>
);
})}
</div>
)}
{/* 表体 */}
<AnimatePresence mode="popLayout">
{table.getRowModel().rows.map((row) => (
<motion.div
key={row.original.code || row.id}
className="table-row-wrapper"
layout="position"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
style={{ position: 'relative' }}
>
<div
className="table-row table-row-scroll"
>
{row.getVisibleCells().map((cell) => {
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
const isNameColumn = columnId === 'fundName';
const rightAlignedColumns = new Set([
'yesterdayChangePercent',
'estimateChangePercent',
'holdingAmount',
'todayProfit',
'holdingProfit',
]);
const align = isNameColumn
? ''
: rightAlignedColumns.has(columnId)
? 'text-right'
: 'text-center';
const cellClassName =
(cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
const style = getCommonPinningStyles(cell.column, false);
return (
<div
key={cell.id}
className={`table-cell ${align} ${cellClassName}`}
style={style}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
);
})}
</div>
</motion.div>
))}
</AnimatePresence>
{table.getRowModel().rows.length === 0 && (
<div className="table-row empty-row">
<div className="table-cell" style={{ textAlign: 'center' }}>
<span className="muted">暂无数据</span>
</div>
</div>
)}
</>
);
}