feat:移动端表格模式重构
This commit is contained in:
588
app/components/MobileFundTable.jsx
Normal file
588
app/components/MobileFundTable.jsx
Normal file
@@ -0,0 +1,588 @@
|
||||
'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 MobileSettingModal from './MobileSettingModal';
|
||||
import { ExitIcon, SettingsIcon, StarIcon } from './Icons';
|
||||
|
||||
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||
'yesterdayChangePercent',
|
||||
'estimateChangePercent',
|
||||
'todayProfit',
|
||||
'holdingProfit',
|
||||
];
|
||||
const MOBILE_COLUMN_HEADERS = {
|
||||
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 (
|
||||
<motion.div
|
||||
ref={setNodeRef}
|
||||
className="table-row-wrapper"
|
||||
layout={isTableDragging ? undefined : 'position'}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
style={{ ...style, position: 'relative' }}
|
||||
{...attributes}
|
||||
>
|
||||
{typeof children === 'function' ? children(setActivatorNodeRef, listeners) : children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端基金列表表格组件(基于 @tanstack/react-table,与 PcFundTable 相同数据结构)
|
||||
*
|
||||
* @param {Object} props - 与 PcFundTable 一致
|
||||
* @param {Array<Object>} props.data - 表格数据(与 pcFundTableData 同结构)
|
||||
* @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] - 是否刷新中
|
||||
* @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,
|
||||
}) {
|
||||
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));
|
||||
} 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));
|
||||
} 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 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 (
|
||||
<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" title={isUpdated ? '今日净值已更新' : ''}>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
{holdingAmountDisplay ? (
|
||||
<span
|
||||
className="muted code-text"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="点击设置持仓"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{holdingAmountDisplay}
|
||||
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||
</span>
|
||||
) : code ? (
|
||||
<span
|
||||
className="muted code-text"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="设置持仓"
|
||||
style={{ 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 });
|
||||
}
|
||||
}}
|
||||
>
|
||||
#{code}
|
||||
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'fundName',
|
||||
header: () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
<span>基金名称</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
setSettingModalOpen(true);
|
||||
}}
|
||||
title="个性化设置"
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
minWidth: '28px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--text)',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<SettingsIcon width="18" height="18" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
cell: (info) => <MobileFundNameCell info={info} />,
|
||||
meta: { align: 'left', cellClassName: 'name-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: 0 }}>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '11px' }}>{date}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'change-cell' },
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '11px' }}>{time}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'est-change-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: '持有收益',
|
||||
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' },
|
||||
},
|
||||
],
|
||||
[currentTab, favorites, refreshing]
|
||||
);
|
||||
|
||||
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 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 (['yesterdayChangePercent', 'estimateChangePercent', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right';
|
||||
return 'text-right';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-fund-table">
|
||||
<div className="mobile-fund-table-scroll">
|
||||
{headerGroup && (
|
||||
<div className="table-header-row mobile-fund-table-header">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const columnId = header.column.id;
|
||||
const pinClass = getPinClass(columnId, true);
|
||||
const alignClass = getAlignClass(columnId);
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className={`table-header-cell ${alignClass} ${pinClass}`}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
>
|
||||
<SortableContext
|
||||
items={data.map((item) => item.code)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<SortableRow
|
||||
key={row.original.code || row.id}
|
||||
row={row}
|
||||
isTableDragging={!!activeId}
|
||||
disabled={sortBy !== 'default'}
|
||||
>
|
||||
{(setActivatorNodeRef, listeners) => (
|
||||
<div
|
||||
ref={sortBy === 'default' ? setActivatorNodeRef : undefined}
|
||||
className="table-row"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
{...(sortBy === 'default' ? listeners : {})}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const columnId = cell.column.id;
|
||||
const pinClass = getPinClass(columnId, false);
|
||||
const alignClass = getAlignClass(columnId);
|
||||
const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SortableRow>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<div className="table-row empty-row">
|
||||
<div className="table-cell" style={{ textAlign: 'center' }}>
|
||||
<span className="muted">暂无数据</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MobileSettingModal
|
||||
open={settingModalOpen}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
app/components/MobileSettingModal.jsx
Normal file
220
app/components/MobileSettingModal.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion, Reorder } from 'framer-motion';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
|
||||
|
||||
/**
|
||||
* 移动端表格个性化设置弹框(底部抽屉)
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 是否打开
|
||||
* @param {() => void} props.onClose - 关闭回调
|
||||
* @param {Array<{id: string, header: string}>} props.columns - 非冻结列(id + 表头名称)
|
||||
* @param {Record<string, boolean>} [props.columnVisibility] - 列显示状态映射(id => 是否显示)
|
||||
* @param {(newOrder: string[]) => void} props.onColumnReorder - 列顺序变更回调
|
||||
* @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调
|
||||
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调
|
||||
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
|
||||
*/
|
||||
export default function MobileSettingModal({
|
||||
open,
|
||||
onClose,
|
||||
columns = [],
|
||||
columnVisibility,
|
||||
onColumnReorder,
|
||||
onToggleColumnVisibility,
|
||||
onResetColumnOrder,
|
||||
onResetColumnVisibility,
|
||||
}) {
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setResetConfirmOpen(false);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleReorder = (newItems) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
onColumnReorder?.(newOrder);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="mobile-setting-overlay"
|
||||
className="mobile-setting-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="个性化设置"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
style={{ zIndex: 10001 }}
|
||||
>
|
||||
<motion.div
|
||||
className="mobile-setting-drawer glass"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mobile-setting-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>个性化设置</span>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mobile-setting-body">
|
||||
<h3 className="mobile-setting-subtitle">表头设置</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<p className="muted" style={{ fontSize: '13px', margin: 0 }}>
|
||||
拖拽调整列顺序
|
||||
</p>
|
||||
{(onResetColumnOrder || onResetColumnVisibility) && (
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => setResetConfirmOpen(true)}
|
||||
title="重置表头设置"
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ResetIcon width="16" height="16" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{columns.length === 0 ? (
|
||||
<div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}>
|
||||
暂无可配置列
|
||||
</div>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={columns}
|
||||
onReorder={handleReorder}
|
||||
className="mobile-setting-list"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{columns.map((item, index) => (
|
||||
<Reorder.Item
|
||||
key={item.id || `col-${index}`}
|
||||
value={item}
|
||||
className="mobile-setting-item glass"
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 35,
|
||||
mass: 1,
|
||||
layout: { duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="drag-handle"
|
||||
style={{
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<DragIcon width="18" height="18" />
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: '14px' }}>{item.header}</span>
|
||||
{onToggleColumnVisibility && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button pc-table-column-switch"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleColumnVisibility(item.id, columnVisibility?.[item.id] === false);
|
||||
}}
|
||||
title={columnVisibility?.[item.id] === false ? '显示' : '隐藏'}
|
||||
style={{
|
||||
border: 'none',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span className={`dca-toggle-track ${columnVisibility?.[item.id] !== false ? 'enabled' : ''}`}>
|
||||
<span
|
||||
className="dca-toggle-thumb"
|
||||
style={{ left: columnVisibility?.[item.id] !== false ? 16 : 2 }}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
{resetConfirmOpen && (
|
||||
<ConfirmModal
|
||||
key="mobile-reset-confirm"
|
||||
title="重置表头设置"
|
||||
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||
onConfirm={() => {
|
||||
onResetColumnOrder?.();
|
||||
onResetColumnVisibility?.();
|
||||
setResetConfirmOpen(false);
|
||||
}}
|
||||
onCancel={() => setResetConfirmOpen(false)}
|
||||
confirmText="重置"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
@@ -504,7 +504,7 @@ export default function PcFundTable({
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||
}}
|
||||
title="编辑持仓"
|
||||
style={{ border: 'none', width: '28px', height: '28px', marginLeft: -6 }}
|
||||
style={{ border: 'none', width: '28px', height: '28px', marginLeft: -6, backgroundColor: 'transparent' }}
|
||||
>
|
||||
<SettingsIcon width="14" height="14" />
|
||||
</button>
|
||||
@@ -606,7 +606,7 @@ export default function PcFundTable({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="row" style={{ justifyContent: 'center', gap: 4 }}>
|
||||
<button
|
||||
className="icon-button danger"
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -200,7 +200,6 @@ export default function PcTableSettingModal({
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onResetSizing();
|
||||
}}
|
||||
style={{
|
||||
|
||||
245
app/globals.css
245
app/globals.css
@@ -1421,6 +1421,155 @@ input[type="number"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 移动端基金表格:横向滚动容器,基金名称与操作列固定 */
|
||||
.mobile-fund-table {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-fund-table-scroll {
|
||||
min-width: 520px;
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-header-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr 1fr 1.2fr 1.2fr;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-header-row .table-header-cell {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-row {
|
||||
grid-template-columns: 140px 1fr 1fr 1.2fr 1.2fr;
|
||||
grid-template-areas: unset;
|
||||
gap: 12px;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
/* 长按拖拽时禁止触发文字选择 */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
.mobile-fund-table .table-header-row {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
[data-theme="light"] .mobile-fund-table .table-row,
|
||||
[data-theme="light"] .mobile-fund-table .table-header-row {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-row .table-cell {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 基金名称列固定左侧:右侧阴影突出固定列,覆盖行 border-bottom */
|
||||
.mobile-fund-table .table-header-cell-pin-left,
|
||||
.mobile-fund-table .table-cell-pin-left {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
box-shadow: 4px 0 10px -2px rgba(0, 0, 0, 0.12);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
[data-theme="light"] .mobile-fund-table .table-header-cell-pin-left,
|
||||
[data-theme="light"] .mobile-fund-table .table-cell-pin-left {
|
||||
box-shadow: 4px 0 10px -2px rgba(0, 0, 0, 0.08);
|
||||
border-right-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-header-cell-pin-left {
|
||||
background: var(--table-pinned-header-bg);
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-cell-pin-left {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* 移动端表格取消行悬停高亮 */
|
||||
.mobile-fund-table .table-row:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-row:hover .table-cell-pin-left {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* 固定列右侧:同上,保证竖向分隔连续 */
|
||||
.mobile-fund-table .table-header-cell-pin-right,
|
||||
.mobile-fund-table .table-cell-pin-right {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
box-shadow: -4px 0 24px -4px rgba(0, 0, 0, 0.12);
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mobile-fund-table .table-header-cell-pin-right,
|
||||
[data-theme="light"] .mobile-fund-table .table-cell-pin-right {
|
||||
border-left-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-header-cell-pin-right {
|
||||
background: var(--table-pinned-header-bg);
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-cell-pin-right {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-row:hover .table-cell-pin-right {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.mobile-fund-table .name-cell {
|
||||
grid-area: unset;
|
||||
align-self: stretch;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-header-row .table-header-cell-pin-left {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-row .name-cell .name-cell-content {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* 移动端表格表体基金名称字体缩小 */
|
||||
.mobile-fund-table .table-row .name-cell .name-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-row .name-cell .code-text {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mobile-fund-table .value-cell {
|
||||
grid-area: unset;
|
||||
}
|
||||
|
||||
.mobile-fund-table .change-cell {
|
||||
grid-area: unset;
|
||||
}
|
||||
|
||||
.mobile-fund-table .est-change-cell,
|
||||
.mobile-fund-table .profit-cell,
|
||||
.mobile-fund-table .time-cell {
|
||||
grid-area: unset;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
grid-template-columns: 1fr 80px 100px;
|
||||
grid-template-areas:
|
||||
@@ -1463,6 +1612,18 @@ input[type="number"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 移动端表格内保留持仓、收益、操作列显示 */
|
||||
.mobile-fund-table .holding-amount-cell,
|
||||
.mobile-fund-table .holding-cell,
|
||||
.mobile-fund-table .action-cell {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 操作列删除按钮在移动端表格内需显示(避免被 .action-cell .danger 隐藏) */
|
||||
.mobile-fund-table .action-cell .icon-button.danger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.table-cell.time-cell span {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
@@ -1473,9 +1634,14 @@ input[type="number"] {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 非 mobile-fund-table 下的操作列删除按钮隐藏(如旧列表布局);mobile-fund-table 内由上面覆盖为显示 */
|
||||
.action-cell .danger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-fund-table .action-cell .danger {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.swipe-action-bg {
|
||||
@@ -1715,6 +1881,85 @@ input[type="number"] {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 移动端表格设置底部抽屉 */
|
||||
.mobile-setting-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(2, 6, 23, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mobile-setting-drawer {
|
||||
width: 100%;
|
||||
max-height: 75vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px 20px 0 0;
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.mobile-setting-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-setting-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.mobile-setting-subtitle {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mobile-setting-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mobile-setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.mobile-setting-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mobile-setting-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .mobile-setting-drawer .dca-toggle-thumb {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 定投按钮:暗色主题 */
|
||||
.dca-btn {
|
||||
background: rgba(34, 211, 238, 0.12);
|
||||
|
||||
311
app/page.jsx
311
app/page.jsx
@@ -46,6 +46,7 @@ import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuati
|
||||
import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds, extractFundNamesWithLLM } from './api/fund';
|
||||
import packageJson from '../package.json';
|
||||
import PcFundTable from './components/PcFundTable';
|
||||
import MobileFundTable from './components/MobileFundTable';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -3952,285 +3953,59 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'list' && isMobile && (
|
||||
<div className="table-header-row">
|
||||
<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 className="table-header-cell text-center">操作</div>
|
||||
</div>
|
||||
<MobileFundTable
|
||||
data={pcFundTableData}
|
||||
refreshing={refreshing}
|
||||
currentTab={currentTab}
|
||||
favorites={favorites}
|
||||
sortBy={sortBy}
|
||||
onReorder={handleReorder}
|
||||
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] }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout">
|
||||
{displayFunds.map((f) =>
|
||||
(viewMode === 'list' && !isMobile) ? null : (
|
||||
{viewMode === 'card' && displayFunds.map((f) => (
|
||||
<motion.div
|
||||
layout="position"
|
||||
key={f.code}
|
||||
className={viewMode === 'card' ? 'col-6' : 'table-row-wrapper'}
|
||||
className="col-6"
|
||||
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' }}
|
||||
>
|
||||
{viewMode === 'list' && isMobile && (
|
||||
<div
|
||||
className="swipe-action-bg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // 阻止冒泡,防止触发全局收起导致状态混乱
|
||||
if (refreshing) return;
|
||||
requestRemoveFund(f);
|
||||
}}
|
||||
style={{ pointerEvents: refreshing ? 'none' : 'auto', opacity: refreshing ? 0.6 : 1 }}
|
||||
>
|
||||
<TrashIcon width="18" height="18" />
|
||||
<span>删除</span>
|
||||
</div>
|
||||
)}
|
||||
<motion.div
|
||||
className={viewMode === 'card' ? 'glass card' : 'table-row'}
|
||||
drag={viewMode === 'list' && isMobile ? "x" : false}
|
||||
dragConstraints={{ left: -80, right: 0 }}
|
||||
dragElastic={0.1}
|
||||
// 增加 dragDirectionLock 确保在垂直滚动时不会轻易触发水平拖拽
|
||||
dragDirectionLock={true}
|
||||
// 调整触发阈值,只有明显的水平拖拽意图才响应
|
||||
onDragStart={(event, info) => {
|
||||
// 如果水平移动距离小于垂直移动距离,或者水平速度很小,视为垂直滚动意图,不进行拖拽处理
|
||||
// framer-motion 的 dragDirectionLock 已经处理了大部分情况,但可以进一步微调体验
|
||||
}}
|
||||
// 如果当前行不是被选中的行,强制回到原点 (x: 0)
|
||||
animate={viewMode === 'list' && isMobile ? { x: swipedFundCode === f.code ? -80 : 0 } : undefined}
|
||||
onDragEnd={(e, { offset, velocity }) => {
|
||||
if (viewMode === 'list' && isMobile) {
|
||||
if (offset.x < -40) {
|
||||
setSwipedFundCode(f.code);
|
||||
} else {
|
||||
setSwipedFundCode(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// 阻止事件冒泡,避免触发全局的 click listener 导致立刻被收起
|
||||
// 只有在已经展开的情况下点击自身才需要阻止冒泡(或者根据需求调整)
|
||||
// 这里我们希望:点击任何地方都收起。
|
||||
// 如果点击的是当前行,且不是拖拽操作,上面的全局 listener 会处理收起。
|
||||
// 但为了防止点击行内容触发收起后又立即触发行的其他点击逻辑(如果有的话),
|
||||
// 可以在这里处理。不过当前需求是“点击其他区域收起”,
|
||||
// 实际上全局 listener 已经覆盖了“点击任何区域(包括其他行)收起”。
|
||||
// 唯一的问题是:点击当前行的“删除按钮”时,会先触发全局 click 导致收起,然后触发删除吗?
|
||||
// 删除按钮在底层,通常不会受影响,因为 React 事件和原生事件的顺序。
|
||||
// 但为了保险,删除按钮的 onClick 应该阻止冒泡。
|
||||
|
||||
// 如果当前行已展开,点击行内容(非删除按钮)应该收起
|
||||
if (viewMode === 'list' && isMobile && swipedFundCode === f.code) {
|
||||
e.stopPropagation(); // 阻止冒泡,自己处理收起,避免触发全局再次处理
|
||||
setSwipedFundCode(null);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: viewMode === 'list' ? 'var(--bg)' : undefined,
|
||||
position: 'relative',
|
||||
zIndex: 1
|
||||
}}
|
||||
className="glass card"
|
||||
style={{ position: 'relative', zIndex: 1 }}
|
||||
>
|
||||
{viewMode === 'list' ? (
|
||||
<>
|
||||
<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.name}
|
||||
</span>
|
||||
<span
|
||||
className="muted code-text"
|
||||
title={f.jzrq === todayStr ? "今日净值已更新" : ""}
|
||||
>
|
||||
{(() => {
|
||||
const holding = holdings[f.code];
|
||||
const profit = getHoldingProfit(f, holding);
|
||||
return profit ? `¥${profit.amount.toFixed(2)}` : `#${f.code}`;
|
||||
})()}
|
||||
{f.jzrq === todayStr && <span className="updated-indicator">✓</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const hasTodayData = f.jzrq === todayStr;
|
||||
const shouldHideChange = isTradingDay && !hasTodayData;
|
||||
|
||||
if (!shouldHideChange) {
|
||||
// 如果涨跌幅列显示(即非交易时段或今日净值已更新),则显示单位净值和真实涨跌幅
|
||||
return (
|
||||
<>
|
||||
<div className="table-cell text-right value-cell">
|
||||
<span style={{ fontWeight: 700 }}>{f.dwjz ?? '—'}</span>
|
||||
</div>
|
||||
<div className="table-cell text-right change-cell">
|
||||
<span className={f.zzl > 0 ? 'up' : f.zzl < 0 ? 'down' : ''} style={{ fontWeight: 700 }}>
|
||||
{f.zzl !== undefined ? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
// 否则显示估值净值和估值涨跌幅
|
||||
// 如果是无估值数据的基金,直接显示净值数据
|
||||
if (f.noValuation) {
|
||||
return (
|
||||
<>
|
||||
<div className="table-cell text-right value-cell">
|
||||
<span style={{ fontWeight: 700 }}>{f.dwjz ?? '—'}</span>
|
||||
</div>
|
||||
<div className="table-cell text-right change-cell">
|
||||
<span className={f.zzl > 0 ? 'up' : f.zzl < 0 ? 'down' : ''} style={{ fontWeight: 700 }}>
|
||||
{f.zzl !== undefined && f.zzl !== null ? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="table-cell text-right value-cell">
|
||||
<span style={{ fontWeight: 700 }}>{f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')}</span>
|
||||
</div>
|
||||
<div className="table-cell text-right change-cell">
|
||||
<span className={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.estPricedCoverage > 0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (isNumber(f.gszzl) ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
<div className="table-cell text-right time-cell">
|
||||
<span className="muted" style={{ fontSize: '12px' }}>{f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')}</span>
|
||||
</div>
|
||||
{!isMobile && (() => {
|
||||
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"
|
||||
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>
|
||||
);
|
||||
})()}
|
||||
{!isMobile && (() => {
|
||||
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 className="table-cell text-center action-cell" style={{ gap: 4 }}>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<>
|
||||
<div className="row" style={{ marginBottom: 10 }}>
|
||||
<div className="title">
|
||||
{currentTab !== 'all' && currentTab !== 'fav' ? (
|
||||
@@ -4491,11 +4266,9 @@ export default function HomePage() {
|
||||
theme={theme}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user