feat:重构PC端表格

This commit is contained in:
hzm
2026-02-27 08:12:01 +08:00
parent f33c6397c0
commit b27ab48d27
5 changed files with 746 additions and 184 deletions

View File

@@ -0,0 +1,555 @@
'use client';
import { useEffect, useMemo, useRef } 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 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',
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>
)}
</>
);
}

View File

@@ -844,7 +844,6 @@ input[type="number"] {
min-height: 52px;
box-sizing: border-box;
border-bottom: 1px solid var(--border);
transition: background-color 0.2s ease;
}
.table-row-scroll:hover,
@@ -895,16 +894,15 @@ input[type="number"] {
.table-row {
display: grid;
grid-template-columns: 2.2fr 0.8fr 1fr 1fr 1.2fr 1.2fr 1.2fr;
grid-template-columns: 2.2fr 0.8fr 1fr 1fr 1.2fr 1.2fr 1.2fr 0.5fr;
align-items: center;
gap: 12px;
padding: 12px 24px !important;
border-bottom: 1px solid var(--border);
transition: background-color 0.2s ease;
}
.table-row:hover {
background: rgba(255, 255, 255, 0.03);
background: #2a394b;
}
.table-row:last-child {
@@ -913,10 +911,10 @@ input[type="number"] {
.table-header-row {
display: grid;
grid-template-columns: 2.2fr 0.8fr 1fr 1fr 1.2fr 1.2fr 1.2fr;
grid-template-columns: 2.2fr 0.8fr 1fr 1fr 1.2fr 1.2fr 1.2fr 0.5fr;
gap: 12px;
padding: 16px 24px;
background: rgba(255, 255, 255, 0.05);
background: #2a394b;
border-bottom: 1px solid var(--border);
}

View File

@@ -43,6 +43,7 @@ import { supabase, isSupabaseConfigured } from './lib/supabase';
import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries';
import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds, extractFundNamesWithLLM } from './api/fund';
import packageJson from '../package.json';
import PcFundTable from './components/PcFundTable';
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -560,7 +561,7 @@ export default function HomePage() {
}, []);
// 计算持仓收益
const getHoldingProfit = (fund, holding) => {
const getHoldingProfit = useCallback((fund, holding) => {
if (!holding || !isNumber(holding.share)) return null;
const hasTodayData = fund.jzrq === todayStr;
@@ -634,35 +635,121 @@ export default function HomePage() {
profitToday,
profitTotal
};
};
}, [isTradingDay, todayStr]);
// 过滤和排序后的基金列表
const displayFunds = funds
.filter(f => {
if (currentTab === 'all') return true;
if (currentTab === 'fav') return favorites.has(f.code);
const group = groups.find(g => g.id === currentTab);
return group ? group.codes.includes(f.code) : true;
})
.sort((a, b) => {
if (sortBy === 'yield') {
const valA = isNumber(a.estGszzl) ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0);
const valB = isNumber(b.estGszzl) ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0);
return sortOrder === 'asc' ? valA - valB : valB - valA;
}
if (sortBy === 'holding') {
const pa = getHoldingProfit(a, holdings[a.code]);
const pb = getHoldingProfit(b, holdings[b.code]);
const valA = pa?.profitTotal ?? Number.NEGATIVE_INFINITY;
const valB = pb?.profitTotal ?? Number.NEGATIVE_INFINITY;
return sortOrder === 'asc' ? valA - valB : valB - valA;
}
if (sortBy === 'name') {
return sortOrder === 'asc' ? a.name.localeCompare(b.name, 'zh-CN') : b.name.localeCompare(a.name, 'zh-CN');
}
return 0;
});
const displayFunds = useMemo(
() => funds
.filter(f => {
if (currentTab === 'all') return true;
if (currentTab === 'fav') return favorites.has(f.code);
const group = groups.find(g => g.id === currentTab);
return group ? group.codes.includes(f.code) : true;
})
.sort((a, b) => {
if (sortBy === 'yield') {
const valA = isNumber(a.estGszzl) ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0);
const valB = isNumber(b.estGszzl) ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0);
return sortOrder === 'asc' ? valA - valB : valB - valA;
}
if (sortBy === 'holding') {
const pa = getHoldingProfit(a, holdings[a.code]);
const pb = getHoldingProfit(b, holdings[b.code]);
const valA = pa?.profitTotal ?? Number.NEGATIVE_INFINITY;
const valB = pb?.profitTotal ?? Number.NEGATIVE_INFINITY;
return sortOrder === 'asc' ? valA - valB : valB - valA;
}
if (sortBy === 'name') {
return sortOrder === 'asc' ? a.name.localeCompare(b.name, 'zh-CN') : b.name.localeCompare(a.name, 'zh-CN');
}
return 0;
}),
[funds, currentTab, favorites, groups, sortBy, sortOrder, holdings, getHoldingProfit],
);
// PC 端表格数据(用于 PcFundTable
const pcFundTableData = useMemo(
() =>
displayFunds.map((f) => {
const hasTodayData = f.jzrq === todayStr;
const shouldHideChange = isTradingDay && !hasTodayData;
const navOrEstimate = !shouldHideChange
? (f.dwjz ?? '—')
: (f.noValuation
? (f.dwjz ?? '—')
: (f.estPricedCoverage > 0.05
? (f.estGsz != null ? Number(f.estGsz).toFixed(4) : '—')
: (f.gsz ?? '—')));
const yesterdayChangePercent =
f.zzl != null && f.zzl !== ''
? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%`
: '—';
const yesterdayChangeValue =
f.zzl != null && f.zzl !== '' ? Number(f.zzl) : null;
const yesterdayDate = f.jzrq || '-';
const estimateChangePercent = f.noValuation
? '—'
: (f.estPricedCoverage > 0.05
? (f.estGszzl != null
? `${f.estGszzl > 0 ? '+' : ''}${Number(f.estGszzl).toFixed(2)}%`
: '—')
: (isNumber(f.gszzl)
? `${f.gszzl > 0 ? '+' : ''}${Number(f.gszzl).toFixed(2)}%`
: (f.gszzl ?? '—')));
const estimateChangeValue = f.noValuation
? null
: (f.estPricedCoverage > 0.05
? (isNumber(f.estGszzl) ? Number(f.estGszzl) : null)
: (isNumber(f.gszzl) ? Number(f.gszzl) : null));
const estimateTime = f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-');
const holding = holdings[f.code];
const profit = getHoldingProfit(f, holding);
const amount = profit ? profit.amount : null;
const holdingAmount =
amount == null ? '未设置' : `¥${amount.toFixed(2)}`;
const holdingAmountValue = amount;
const profitToday = profit ? profit.profitToday : null;
const todayProfit =
profitToday == null
? ''
: `${profitToday > 0 ? '+' : profitToday < 0 ? '-' : ''}¥${Math.abs(profitToday).toFixed(2)}`;
const todayProfitValue = profitToday;
const total = profit ? profit.profitTotal : null;
const holdingProfit =
total == null
? ''
: `${total > 0 ? '+' : total < 0 ? '-' : ''}¥${Math.abs(total).toFixed(2)}`;
const holdingProfitValue = total;
return {
rawFund: f,
code: f.code,
fundName: f.name,
isUpdated: f.jzrq === todayStr,
navOrEstimate,
yesterdayChangePercent,
yesterdayChangeValue,
yesterdayDate,
estimateChangePercent,
estimateChangeValue,
estimateChangeMuted: f.noValuation,
estimateTime,
holdingAmount,
holdingAmountValue,
todayProfit,
todayProfitValue,
holdingProfit,
holdingProfitValue,
};
}),
[displayFunds, holdings, isTradingDay, todayStr, getHoldingProfit],
);
// 自动滚动选中 Tab 到可视区域
useEffect(() => {
@@ -3639,159 +3726,47 @@ export default function HomePage() {
className={viewMode === 'card' ? 'grid' : 'table-container glass'}
>
<div className={viewMode === 'card' ? 'grid col-12' : ''} style={viewMode === 'card' ? { gridColumn: 'span 12', gap: 16 } : {}}>
{/* PC 列表:左右分块,左侧 8 列可横向滚动,右侧操作列固定 */}
{/* PC 列表:使用 PcFundTable + 右侧冻结操作列 */}
{viewMode === 'list' && !isMobile && (
<div className="table-pc-wrap">
<div className="table-scroll-area">
<div className="table-scroll-area-inner">
<div className="table-header-row table-header-row-scroll">
<div className="table-header-cell">基金名称</div>
<div className="table-header-cell text-right">净值/估值</div>
<div className="table-header-cell text-right">昨日涨跌幅</div>
<div className="table-header-cell text-right">估值涨跌幅</div>
<div className="table-header-cell text-right">持仓金额</div>
<div className="table-header-cell text-right">当日收益</div>
<div className="table-header-cell text-right">持有收益</div>
</div>
<AnimatePresence mode="popLayout">
{displayFunds.map((f) => (
<motion.div
layout="position"
key={f.code}
className="table-row-wrapper"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
style={{ position: 'relative', overflow: 'hidden' }}
onMouseEnter={() => setHoveredPcRowCode(f.code)}
onMouseLeave={() => setHoveredPcRowCode(null)}
>
<div className={`table-row table-row-scroll ${hoveredPcRowCode === f.code ? 'row-hovered' : ''}`} style={{ background: hoveredPcRowCode === f.code ? 'rgba(255,255,255,0.08)' : 'var(--bg)', position: 'relative', zIndex: 1 }}>
<div className="table-cell name-cell">
{currentTab !== 'all' && currentTab !== 'fav' ? (
<button className="icon-button fav-button" onClick={(e) => { e.stopPropagation(); removeFundFromCurrentGroup(f.code); }} title="从当前分组移除">
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
</button>
) : (
<button className={`icon-button fav-button ${favorites.has(f.code) ? 'active' : ''}`} onClick={(e) => { e.stopPropagation(); toggleFavorite(f.code); }} title={favorites.has(f.code) ? "取消自选" : "添加自选"}>
<StarIcon width="18" height="18" filled={favorites.has(f.code)} />
</button>
)}
<div className="title-text">
<span className={`name-text ${f.jzrq === todayStr ? 'updated' : ''}`} title={f.jzrq === todayStr ? "今日净值已更新" : ""}>{f.name}</span>
<span className="muted code-text">#{f.code}</span>
</div>
</div>
{(() => {
const hasTodayData = f.jzrq === todayStr;
const shouldHideChange = isTradingDay && !hasTodayData;
const valueDisplay = !shouldHideChange ? (f.dwjz ?? '—') : (f.noValuation ? (f.dwjz ?? '—') : (f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')));
return (
<div className="table-cell text-right value-cell">
<span style={{ fontWeight: 700 }}>{valueDisplay}</span>
</div>
);
})()}
<div className="table-cell text-right change-cell">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
<span className={f.zzl > 0 ? 'up' : f.zzl < 0 ? 'down' : ''} style={{ fontWeight: 700 }}>
{f.zzl != null && f.zzl !== '' ? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : '—'}
</span>
<span className="muted" style={{ fontSize: '12px' }}>
{f.jzrq || '-'}
</span>
</div>
</div>
<div className="table-cell text-right est-change-cell">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
<span className={f.noValuation ? 'muted' : (f.estPricedCoverage > 0.05 ? (f.estGszzl > 0 ? 'up' : f.estGszzl < 0 ? 'down' : '') : (Number(f.gszzl) > 0 ? 'up' : Number(f.gszzl) < 0 ? 'down' : ''))} style={{ fontWeight: 700 }}>
{f.noValuation ? '—' : (f.estPricedCoverage > 0.05 ? (f.estGszzl != null ? `${f.estGszzl > 0 ? '+' : ''}${Number(f.estGszzl).toFixed(2)}%` : '—') : (isNumber(f.gszzl) ? `${f.gszzl > 0 ? '+' : ''}${Number(f.gszzl).toFixed(2)}%` : (f.gszzl ?? '—')))}
</span>
<span className="muted" style={{ fontSize: '12px' }}>
{f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')}
</span>
</div>
</div>
{(() => {
const holding = holdings[f.code];
const profit = getHoldingProfit(f, holding);
const amount = profit ? profit.amount : null;
if (amount === null) {
return (
<div className="table-cell text-right holding-amount-cell" title="设置持仓" onClick={(e) => { e.stopPropagation(); setHoldingModal({ open: true, fund: f }); }}>
<span className="muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '12px', cursor: 'pointer' }}>未设置 <SettingsIcon width="12" height="12" /></span>
</div>
);
}
return (
<div className="table-cell text-right holding-amount-cell" title="点击设置持仓" onClick={(e) => { e.stopPropagation(); setActionModal({ open: true, fund: f }); }}>
<span style={{ fontWeight: 700, marginRight: 6 }}>¥{amount.toFixed(2)}</span>
<button className="icon-button no-hover" onClick={(e) => { e.stopPropagation(); setActionModal({ open: true, fund: f }); }} title="编辑持仓" style={{ border: 'none', width: '28px', height: '28px', marginLeft: -6 }}>
<SettingsIcon width="14" height="14" />
</button>
</div>
);
})()}
{(() => {
const holding = holdings[f.code];
const profit = getHoldingProfit(f, holding);
const profitValue = profit ? profit.profitToday : null;
const hasProfit = profitValue !== null;
return (
<div className="table-cell text-right profit-cell">
<span className={hasProfit ? (profitValue > 0 ? 'up' : profitValue < 0 ? 'down' : '') : 'muted'} style={{ fontWeight: 700 }}>
{hasProfit ? `${profitValue > 0 ? '+' : profitValue < 0 ? '-' : ''}¥${Math.abs(profitValue).toFixed(2)}` : ''}
</span>
</div>
);
})()}
{(() => {
const holding = holdings[f.code];
const profit = getHoldingProfit(f, holding);
const total = profit ? profit.profitTotal : null;
const principal = holding && holding.cost && holding.share ? holding.cost * holding.share : 0;
const asPercent = percentModes[f.code];
const hasTotal = total !== null;
const formatted = hasTotal ? (asPercent && principal > 0 ? `${total > 0 ? '+' : total < 0 ? '-' : ''}${Math.abs((total / principal) * 100).toFixed(2)}%` : `${total > 0 ? '+' : total < 0 ? '-' : ''}¥${Math.abs(total).toFixed(2)}`) : '';
const cls = hasTotal ? (total > 0 ? 'up' : total < 0 ? 'down' : '') : 'muted';
return (
<div className="table-cell text-right holding-cell" title="点击切换金额/百分比" onClick={(e) => { e.stopPropagation(); if (hasTotal) setPercentModes(prev => ({ ...prev, [f.code]: !prev[f.code] })); }} style={{ cursor: hasTotal ? 'pointer' : 'default' }}>
<span className={cls} style={{ fontWeight: 700 }}>{formatted}</span>
</div>
);
})()}
</div>
</motion.div>
))}
</AnimatePresence>
<div className="table-pc-wrap">
<div className="table-scroll-area">
<div className="table-scroll-area-inner">
<PcFundTable
data={pcFundTableData}
refreshing={refreshing}
currentTab={currentTab}
favorites={favorites}
onRemoveFund={(row) => {
if (refreshing) return;
if (!row || !row.code) return;
requestRemoveFund({ code: row.code, name: row.fundName });
}}
onToggleFavorite={(row) => {
if (!row || !row.code) return;
toggleFavorite(row.code);
}}
onRemoveFromGroup={(row) => {
if (!row || !row.code) return;
removeFundFromCurrentGroup(row.code);
}}
onHoldingAmountClick={(row, meta) => {
if (!row || !row.code) return;
const fund = row.rawFund || { code: row.code, name: row.fundName };
if (meta?.hasHolding) {
setActionModal({ open: true, fund });
} else {
setHoldingModal({ open: true, fund });
}
}}
onHoldingProfitClick={(row) => {
if (!row || !row.code) return;
if (row.holdingProfitValue == null) return;
setPercentModes(prev => ({ ...prev, [row.code]: !prev[row.code] }));
}}
/>
</div>
</div>
</div>
<div className="table-fixed-col">
<div className="table-header-cell table-header-cell-fixed text-center">操作</div>
<AnimatePresence mode="popLayout">
{displayFunds.map((f) => (
<motion.div
layout="position"
key={f.code}
className={`table-fixed-row ${hoveredPcRowCode === f.code ? 'row-hovered' : ''}`}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
onMouseEnter={() => setHoveredPcRowCode(f.code)}
onMouseLeave={() => setHoveredPcRowCode(null)}
>
<div className="table-cell text-center action-cell">
<button className="icon-button danger" onClick={() => !refreshing && requestRemoveFund(f)} 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>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)}
{viewMode === 'list' && isMobile && (
<div className="table-header-row">
@@ -4734,4 +4709,3 @@ export default function HomePage() {
</div>
);
}

34
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1",
"@supabase/supabase-js": "^2.78.0",
"@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.1",
"dayjs": "^1.11.19",
"framer-motion": "^12.29.2",
@@ -1772,6 +1773,39 @@
"tslib": "^2.8.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",

View File

@@ -14,6 +14,7 @@
"@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1",
"@supabase/supabase-js": "^2.78.0",
"@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.1",
"dayjs": "^1.11.19",
"framer-motion": "^12.29.2",