} [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,
sortBy = 'default',
onReorder,
onCustomSettingsChange,
}) {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
}),
useSensor(KeyboardSensor)
);
const [activeId, setActiveId] = useState(null);
const handleDragStart = (event) => {
setActiveId(event.active.id);
};
const handleDragCancel = () => {
setActiveId(null);
};
const handleDragEnd = (event) => {
const { active, over } = event;
if (active && over && active.id !== over.id) {
const oldIndex = data.findIndex(item => item.code === active.id);
const newIndex = data.findIndex(item => item.code === over.id);
if (oldIndex !== -1 && newIndex !== -1 && onReorder) {
onReorder(oldIndex, newIndex);
}
}
setActiveId(null);
};
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));
onCustomSettingsChange?.();
} catch { }
};
const getStoredColumnOrder = () => {
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?.pcTableColumnOrder;
if (!Array.isArray(order) || order.length === 0) return null;
const valid = order.filter((id) => NON_FROZEN_COLUMN_IDS.includes(id));
const missing = NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
return [...valid, ...missing];
} catch {
return null;
}
};
const persistColumnOrder = (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, pcTableColumnOrder: nextOrder }
: { pcTableColumnOrder: nextOrder };
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
onCustomSettingsChange?.();
} catch { }
};
const getStoredColumnVisibility = () => {
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?.pcTableColumnVisibility;
if (!visibility || typeof visibility !== 'object') return null;
const normalized = {};
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 persistColumnVisibility = (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, pcTableColumnVisibility: nextVisibility }
: { pcTableColumnVisibility: nextVisibility };
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
onCustomSettingsChange?.();
} catch { }
};
const [columnSizing, setColumnSizing] = useState(() => {
const stored = getStoredColumnSizing();
if (stored.actions) {
const { actions, ...rest } = stored;
return rest;
}
return stored;
});
const [columnOrder, setColumnOrder] = useState(() => getStoredColumnOrder() ?? [...NON_FROZEN_COLUMN_IDS]);
const [columnVisibility, setColumnVisibility] = useState(() => {
const stored = getStoredColumnVisibility();
if (stored) return stored;
const allVisible = {};
NON_FROZEN_COLUMN_IDS.forEach((id) => {
allVisible[id] = true;
});
return allVisible;
});
const [settingModalOpen, setSettingModalOpen] = useState(false);
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const handleResetSizing = () => {
setColumnSizing({});
persistColumnSizing({});
setResetConfirmOpen(false);
};
const handleResetColumnOrder = () => {
const defaultOrder = [...NON_FROZEN_COLUMN_IDS];
setColumnOrder(defaultOrder);
persistColumnOrder(defaultOrder);
};
const handleResetColumnVisibility = () => {
const allVisible = {};
NON_FROZEN_COLUMN_IDS.forEach((id) => {
allVisible[id] = true;
});
setColumnVisibility(allVisible);
persistColumnVisibility(allVisible);
};
const handleToggleColumnVisibility = (columnId, visible) => {
setColumnVisibility((prev = {}) => {
const next = { ...prev, [columnId]: visible };
persistColumnVisibility(next);
return next;
});
};
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 FundNameCell = ({ 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';
const rowContext = useContext(SortableRowContext);
return (
{sortBy === 'default' && (
)}
{isGroupTab ? (
) : (
)}
{info.getValue() ?? '—'}
{code ?
#{code}
{isUpdated && ✓}
: null}
);
};
const columns = useMemo(
() => [
{
accessorKey: 'fundName',
header: '基金名称',
size: 265,
minSize: 140,
enablePinning: true,
cell: (info) => ,
meta: {
align: 'left',
cellClassName: 'name-cell',
},
},
{
accessorKey: 'latestNav',
header: '最新净值',
size: 100,
minSize: 80,
cell: (info) => (
{info.getValue() ?? '—'}
),
meta: {
align: 'right',
cellClassName: 'value-cell',
},
},
{
accessorKey: 'estimateNav',
header: '估算净值',
size: 100,
minSize: 80,
cell: (info) => (
{info.getValue() ?? '—'}
),
meta: {
align: 'right',
cellClassName: 'value-cell',
},
},
{
accessorKey: 'yesterdayChangePercent',
header: '昨日涨跌幅',
size: 135,
minSize: 100,
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',
},
},
{
accessorKey: 'estimateChangePercent',
header: '估值涨跌幅',
size: 135,
minSize: 100,
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',
},
},
{
accessorKey: 'holdingAmount',
header: '持仓金额',
size: 135,
minSize: 100,
cell: (info) => {
const original = info.row.original || {};
if (original.holdingAmountValue == null) {
return (
{
e.stopPropagation?.();
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
}
}}
>
未设置
);
}
return (
{
e.stopPropagation?.();
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
}}
>
{info.getValue() ?? '—'}
);
},
meta: {
align: 'right',
cellClassName: 'holding-amount-cell',
},
},
{
accessorKey: 'todayProfit',
header: '当日收益',
size: 135,
minSize: 100,
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',
},
},
{
accessorKey: 'holdingProfit',
header: '持有收益',
size: 135,
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 (
{
if (!hasTotal) return;
e.stopPropagation?.();
onHoldingProfitClickRef.current?.(original);
}}
>
{hasTotal ? (info.getValue() ?? '') : ''}
);
},
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 (
);
},
},
],
[currentTab, favorites, refreshing, sortBy],
);
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,
columnOrder,
columnVisibility,
},
onColumnOrderChange: (updater) => {
setColumnOrder((prev) => {
const next = typeof updater === 'function' ? updater(prev) : prev;
persistColumnOrder(next);
return next;
});
},
onColumnVisibilityChange: (updater) => {
setColumnVisibility((prev = {}) => {
const next = typeof updater === 'function' ? updater(prev) : (updater || {});
persistColumnVisibility(next);
return next;
});
},
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 ? 'var(--table-pinned-header-bg)' : 'var(--row-bg)',
boxShadow: 'none',
textAlign: isNameColumn ? 'left' : 'center',
justifyContent: isNameColumn ? 'flex-start' : 'center',
};
};
return (
{/* 表头 */}
{headerGroup && (
{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 (
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
);
})}
)}
{/* 表体 */}
item.code)}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
{row.getVisibleCells().map((cell) => {
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
const isNameColumn = columnId === 'fundName';
const rightAlignedColumns = new Set([
'latestNav',
'estimateNav',
'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 (
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
);
})}
))}
{table.getRowModel().rows.length === 0 && (
)}
{resetConfirmOpen && (
setResetConfirmOpen(false)}
confirmText="重置"
/>
)}
setSettingModalOpen(false)}
columns={columnOrder.map((id) => ({ id, header: COLUMN_HEADERS[id] ?? id }))}
onColumnReorder={(newOrder) => {
setColumnOrder(newOrder);
persistColumnOrder(newOrder);
}}
columnVisibility={columnVisibility}
onToggleColumnVisibility={handleToggleColumnVisibility}
onResetColumnOrder={handleResetColumnOrder}
onResetColumnVisibility={handleResetColumnVisibility}
onResetSizing={() => setResetConfirmOpen(true)}
/>
);
}