} [props.favorites] - 自选集合
* @param {(row: any) => void} [props.onToggleFavorite] - 添加/取消自选
* @param {(row: any) => void} [props.onRemoveFromGroup] - 从当前分组移除
* @param {(row: any, meta: { hasHolding: boolean }) => void} [props.onHoldingAmountClick] - 点击持仓金额
* @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 groupKey = currentTab ?? 'all';
const getCustomSettingsWithMigration = () => {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {};
if (!parsed || typeof parsed !== 'object') return {};
if (parsed.pcTableColumnOrder != null || parsed.pcTableColumnVisibility != null || parsed.pcTableColumns != null || parsed.mobileTableColumnOrder != null || parsed.mobileTableColumnVisibility != null) {
const all = {
...(parsed.all && typeof parsed.all === 'object' ? parsed.all : {}),
pcTableColumnOrder: parsed.pcTableColumnOrder,
pcTableColumnVisibility: parsed.pcTableColumnVisibility,
pcTableColumns: parsed.pcTableColumns,
mobileTableColumnOrder: parsed.mobileTableColumnOrder,
mobileTableColumnVisibility: parsed.mobileTableColumnVisibility,
};
delete parsed.pcTableColumnOrder;
delete parsed.pcTableColumnVisibility;
delete parsed.pcTableColumns;
delete parsed.mobileTableColumnOrder;
delete parsed.mobileTableColumnVisibility;
parsed.all = all;
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
}
return parsed;
} catch {
return {};
}
};
const buildPcConfigFromGroup = (group) => {
if (!group || typeof group !== 'object') return null;
const sizing = group.pcTableColumns;
const sizingObj = sizing && typeof sizing === 'object'
? Object.fromEntries(Object.entries(sizing).filter(([, v]) => Number.isFinite(v)))
: {};
if (sizingObj.actions) {
const { actions, ...rest } = sizingObj;
Object.assign(sizingObj, rest);
delete sizingObj.actions;
}
const order = Array.isArray(group.pcTableColumnOrder) && group.pcTableColumnOrder.length > 0
? group.pcTableColumnOrder
: null;
const visibility = group.pcTableColumnVisibility && typeof group.pcTableColumnVisibility === 'object'
? group.pcTableColumnVisibility
: null;
return { sizing: sizingObj, order, visibility };
};
const getDefaultPcGroupConfig = () => ({
order: [...NON_FROZEN_COLUMN_IDS],
visibility: null,
sizing: {},
});
const getInitialConfigByGroup = () => {
const parsed = getCustomSettingsWithMigration();
const byGroup = {};
Object.keys(parsed).forEach((k) => {
if (k === 'pcContainerWidth') return;
const group = parsed[k];
const pc = buildPcConfigFromGroup(group);
if (pc) {
byGroup[k] = {
pcTableColumnOrder: pc.order ? (() => {
const valid = pc.order.filter((id) => NON_FROZEN_COLUMN_IDS.includes(id));
const missing = NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
return [...valid, ...missing];
})() : null,
pcTableColumnVisibility: pc.visibility,
pcTableColumns: Object.keys(pc.sizing).length ? pc.sizing : null,
};
}
});
return byGroup;
};
const [configByGroup, setConfigByGroup] = useState(getInitialConfigByGroup);
const currentGroupPc = configByGroup[groupKey];
const defaultPc = getDefaultPcGroupConfig();
const columnOrder = (() => {
const order = currentGroupPc?.pcTableColumnOrder ?? defaultPc.order;
if (!Array.isArray(order) || order.length === 0) return [...NON_FROZEN_COLUMN_IDS];
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];
})();
const columnVisibility = (() => {
const vis = currentGroupPc?.pcTableColumnVisibility ?? null;
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
const allVisible = {};
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
return allVisible;
})();
const columnSizing = (() => {
const s = currentGroupPc?.pcTableColumns;
if (s && typeof s === 'object') {
const out = Object.fromEntries(Object.entries(s).filter(([, v]) => Number.isFinite(v)));
if (out.actions) {
const { actions, ...rest } = out;
return rest;
}
return out;
}
return {};
})();
const persistPcGroupConfig = (updates) => {
if (typeof window === 'undefined') return;
try {
const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {};
const group = parsed[groupKey] && typeof parsed[groupKey] === 'object' ? { ...parsed[groupKey] } : {};
if (updates.pcTableColumnOrder !== undefined) group.pcTableColumnOrder = updates.pcTableColumnOrder;
if (updates.pcTableColumnVisibility !== undefined) group.pcTableColumnVisibility = updates.pcTableColumnVisibility;
if (updates.pcTableColumns !== undefined) group.pcTableColumns = updates.pcTableColumns;
parsed[groupKey] = group;
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
setConfigByGroup((prev) => ({ ...prev, [groupKey]: { ...prev[groupKey], ...updates } }));
onCustomSettingsChange?.();
} catch { }
};
const setColumnOrder = (nextOrderOrUpdater) => {
const next = typeof nextOrderOrUpdater === 'function'
? nextOrderOrUpdater(columnOrder)
: nextOrderOrUpdater;
persistPcGroupConfig({ pcTableColumnOrder: next });
};
const setColumnVisibility = (nextOrUpdater) => {
const next = typeof nextOrUpdater === 'function'
? nextOrUpdater(columnVisibility)
: nextOrUpdater;
persistPcGroupConfig({ pcTableColumnVisibility: next });
};
const setColumnSizing = (nextOrUpdater) => {
const next = typeof nextOrUpdater === 'function'
? nextOrUpdater(columnSizing)
: nextOrUpdater;
const { actions, ...rest } = next || {};
persistPcGroupConfig({ pcTableColumns: rest || {} });
};
const [settingModalOpen, setSettingModalOpen] = useState(false);
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const handleResetSizing = () => {
setColumnSizing({});
setResetConfirmOpen(false);
};
const handleResetColumnOrder = () => {
setColumnOrder([...NON_FROZEN_COLUMN_IDS]);
};
const handleResetColumnVisibility = () => {
const allVisible = {};
NON_FROZEN_COLUMN_IDS.forEach((id) => {
allVisible[id] = true;
});
setColumnVisibility(allVisible);
};
const handleToggleColumnVisibility = (columnId, visible) => {
setColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible }));
};
const onRemoveFundRef = useRef(onRemoveFund);
const onToggleFavoriteRef = useRef(onToggleFavorite);
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
useEffect(() => {
onRemoveFundRef.current = onRemoveFund;
onToggleFavoriteRef.current = onToggleFavorite;
onRemoveFromGroupRef.current = onRemoveFromGroup;
onHoldingAmountClickRef.current = onHoldingAmountClick;
}, [
onRemoveFund,
onToggleFavorite,
onRemoveFromGroup,
onHoldingAmountClick,
]);
const FundNameCell = ({ info }) => {
const original = info.row.original || {};
const code = original.code;
const isUpdated = original.isUpdated;
const hasDca = original.hasDca;
const isFavorites = favorites?.has?.(code);
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
const rowContext = useContext(SortableRowContext);
return (
{sortBy === 'default' && (
)}
{isGroupTab ? (
) : (
)}
{info.getValue() ?? '—'}
{code ?
#{code}
{hasDca && 定}
{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';
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
const percentStr = original.todayProfitPercent ?? '';
return (
{amountStr}
{percentStr ? (
{percentStr}
) : null}
);
},
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';
const amountStr = hasTotal ? (info.getValue() ?? '') : '—';
const percentStr = original.holdingProfitPercent ?? '';
return (
{amountStr}
{percentStr ? (
{percentStr}
) : null}
);
},
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 || {};
return rest || {};
});
},
state: {
columnSizing,
columnOrder,
columnVisibility,
},
onColumnOrderChange: (updater) => {
setColumnOrder(updater);
},
onColumnVisibilityChange: (updater) => {
setColumnVisibility(updater);
},
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);
}}
columnVisibility={columnVisibility}
onToggleColumnVisibility={handleToggleColumnVisibility}
onResetColumnOrder={handleResetColumnOrder}
onResetColumnVisibility={handleResetColumnVisibility}
onResetSizing={() => setResetConfirmOpen(true)}
/>
);
}