feat: PC 端表格拖拽排序

This commit is contained in:
hzm
2026-02-27 20:27:08 +08:00
parent 510664c4d3
commit e7192987f4
5 changed files with 375 additions and 116 deletions

View File

@@ -1,14 +1,75 @@
'use client'; 'use client';
import { useEffect, useMemo, useRef, useState } from 'react'; import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { import {
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
} from '@tanstack/react-table'; } 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 ConfirmModal from './ConfirmModal'; import ConfirmModal from './ConfirmModal';
import { ExitIcon, ResetIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons'; import { DragIcon, ExitIcon, ResetIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
const SortableRowContext = createContext({
setActivatorNodeRef: null,
listeners: null,
});
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)' } : {}),
};
const contextValue = useMemo(
() => ({ setActivatorNodeRef, listeners }),
[setActivatorNodeRef, listeners]
);
return (
<SortableRowContext.Provider value={contextValue}>
<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}
>
{children}
</motion.div>
</SortableRowContext.Provider>
);
}
/** /**
* PC 端基金列表表格组件(基于 @tanstack/react-table * PC 端基金列表表格组件(基于 @tanstack/react-table
@@ -45,7 +106,39 @@ export default function PcFundTable({
onHoldingAmountClick, onHoldingAmountClick,
onHoldingProfitClick, onHoldingProfitClick,
refreshing = false, refreshing = false,
sortBy = 'default',
onReorder,
}) { }) {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
}),
useSensor(KeyboardSensor)
);
const [activeId, setActiveId] = useState(null);
const handleDragStart = (event) => {
setActiveId(event.active.id);
};
const handleDragCancel = () => {
setActiveId(null);
};
const handleDragEnd = (event) => {
const { active, over } = event;
if (active && over && active.id !== over.id) {
const oldIndex = data.findIndex(item => item.code === active.id);
const newIndex = data.findIndex(item => item.code === over.id);
if (oldIndex !== -1 && newIndex !== -1 && onReorder) {
onReorder(oldIndex, newIndex);
}
}
setActiveId(null);
};
const getStoredColumnSizing = () => { const getStoredColumnSizing = () => {
if (typeof window === 'undefined') return {}; if (typeof window === 'undefined') return {};
try { try {
@@ -108,22 +201,29 @@ export default function PcFundTable({
onHoldingAmountClick, onHoldingAmountClick,
onHoldingProfitClick, onHoldingProfitClick,
]); ]);
const columns = useMemo(
() => [ const FundNameCell = ({ info }) => {
{
accessorKey: 'fundName',
header: '基金名称',
size: 265,
minSize: 140,
enablePinning: true,
cell: (info) => {
const original = info.row.original || {}; const original = info.row.original || {};
const code = original.code; const code = original.code;
const isUpdated = original.isUpdated; const isUpdated = original.isUpdated;
const isFavorites = favorites?.has?.(code); const isFavorites = favorites?.has?.(code);
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav'; const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
const rowContext = useContext(SortableRowContext);
return ( return (
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{sortBy === 'default' && (
<button
className="icon-button drag-handle"
ref={rowContext?.setActivatorNodeRef}
{...rowContext?.listeners}
style={{ cursor: 'grab', padding: 2, margin: '-2px -4px -2px 0', color: 'var(--muted)', background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="拖拽排序"
onClick={(e) => e.stopPropagation?.()}
>
<DragIcon width="16" height="16" />
</button>
)}
{isGroupTab ? ( {isGroupTab ? (
<button <button
className="icon-button fav-button" className="icon-button fav-button"
@@ -131,7 +231,7 @@ export default function PcFundTable({
e.stopPropagation?.(); e.stopPropagation?.();
onRemoveFromGroupRef.current?.(original); onRemoveFromGroupRef.current?.(original);
}} }}
title="从当前分组移除" title="从分组移除"
> >
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} /> <ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
</button> </button>
@@ -158,7 +258,17 @@ export default function PcFundTable({
</div> </div>
</div> </div>
); );
}, };
const columns = useMemo(
() => [
{
accessorKey: 'fundName',
header: '基金名称',
size: 265,
minSize: 140,
enablePinning: true,
cell: (info) => <FundNameCell info={info} />,
meta: { meta: {
align: 'left', align: 'left',
cellClassName: 'name-cell', cellClassName: 'name-cell',
@@ -188,11 +298,11 @@ export default function PcFundTable({
const date = original.yesterdayDate ?? '-'; const date = original.yesterdayDate ?? '-';
const cls = value > 0 ? 'up' : value < 0 ? 'down' : ''; const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span className={cls} style={{ fontWeight: 700 }}> <span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'} {info.getValue() ?? '—'}
</span> </span>
<span className="muted" style={{ fontSize: '12px' }}> <span className="muted" style={{ fontSize: '11px' }}>
{date} {date}
</span> </span>
</div> </div>
@@ -215,11 +325,11 @@ export default function PcFundTable({
const time = original.estimateTime ?? '-'; const time = original.estimateTime ?? '-';
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : ''; const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span className={cls} style={{ fontWeight: 700 }}> <span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'} {info.getValue() ?? '—'}
</span> </span>
<span className="muted" style={{ fontSize: '12px' }}> <span className="muted" style={{ fontSize: '11px' }}>
{time} {time}
</span> </span>
</div> </div>
@@ -399,7 +509,7 @@ export default function PcFundTable({
}, },
}, },
], ],
[currentTab, favorites, refreshing], [currentTab, favorites, refreshing, sortBy],
); );
const table = useReactTable({ const table = useReactTable({
@@ -557,8 +667,7 @@ export default function PcFundTable({
<div <div
onMouseDown={header.column.getCanResize() ? header.getResizeHandler() : undefined} onMouseDown={header.column.getCanResize() ? header.getResizeHandler() : undefined}
onTouchStart={header.column.getCanResize() ? header.getResizeHandler() : undefined} onTouchStart={header.column.getCanResize() ? header.getResizeHandler() : undefined}
className={`resizer ${ className={`resizer ${header.column.getIsResizing() ? 'isResizing' : ''
header.column.getIsResizing() ? 'isResizing' : ''
} ${header.column.getCanResize() ? '' : 'disabled'}`} } ${header.column.getCanResize() ? '' : 'disabled'}`}
/> />
</div> </div>
@@ -568,18 +677,21 @@ export default function PcFundTable({
)} )}
{/* 表体 */} {/* 表体 */}
<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"> <AnimatePresence mode="popLayout">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<motion.div <SortableRow key={row.original.code || row.id} row={row} isTableDragging={!!activeId} disabled={sortBy !== 'default'}>
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 <div
className="table-row table-row-scroll" className="table-row table-row-scroll"
> >
@@ -615,9 +727,11 @@ export default function PcFundTable({
); );
})} })}
</div> </div>
</motion.div> </SortableRow>
))} ))}
</AnimatePresence> </AnimatePresence>
</SortableContext>
</DndContext>
{table.getRowModel().rows.length === 0 && ( {table.getRowModel().rows.length === 0 && (
<div className="table-row empty-row"> <div className="table-row empty-row">

View File

@@ -1133,7 +1133,7 @@ input[type="number"] {
} }
.tab { .tab {
padding: 0 20px; padding: 0 10px;
border-radius: 8px; border-radius: 8px;
border: none; border: none;
background: transparent; background: transparent;

View File

@@ -640,14 +640,27 @@ export default function HomePage() {
// 过滤和排序后的基金列表 // 过滤和排序后的基金列表
const displayFunds = useMemo( const displayFunds = useMemo(
() => funds () => {
.filter(f => { let filtered = funds.filter(f => {
if (currentTab === 'all') return true; if (currentTab === 'all') return true;
if (currentTab === 'fav') return favorites.has(f.code); if (currentTab === 'fav') return favorites.has(f.code);
const group = groups.find(g => g.id === currentTab); const group = groups.find(g => g.id === currentTab);
return group ? group.codes.includes(f.code) : true; return group ? group.codes.includes(f.code) : true;
}) });
.sort((a, b) => {
if (currentTab !== 'all' && currentTab !== 'fav' && sortBy === 'default') {
const group = groups.find(g => g.id === currentTab);
if (group && group.codes) {
const codeMap = new Map(group.codes.map((code, index) => [code, index]));
filtered.sort((a, b) => {
const indexA = codeMap.get(a.code) ?? Number.MAX_SAFE_INTEGER;
const indexB = codeMap.get(b.code) ?? Number.MAX_SAFE_INTEGER;
return indexA - indexB;
});
}
}
return filtered.sort((a, b) => {
if (sortBy === 'yield') { if (sortBy === 'yield') {
const valA = isNumber(a.estGszzl) ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0); const valA = isNumber(a.estGszzl) ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0);
const valB = isNumber(b.estGszzl) ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0); const valB = isNumber(b.estGszzl) ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0);
@@ -664,7 +677,8 @@ export default function HomePage() {
return sortOrder === 'asc' ? a.name.localeCompare(b.name, 'zh-CN') : b.name.localeCompare(a.name, 'zh-CN'); return sortOrder === 'asc' ? a.name.localeCompare(b.name, 'zh-CN') : b.name.localeCompare(a.name, 'zh-CN');
} }
return 0; return 0;
}), });
},
[funds, currentTab, favorites, groups, sortBy, sortOrder, holdings, getHoldingProfit], [funds, currentTab, favorites, groups, sortBy, sortOrder, holdings, getHoldingProfit],
); );
@@ -1401,7 +1415,7 @@ export default function HomePage() {
const list = JSON.parse(value || '[]'); const list = JSON.parse(value || '[]');
if (!Array.isArray(list)) return ''; if (!Array.isArray(list)) return '';
const codes = list.map((item) => item?.code).filter(Boolean); const codes = list.map((item) => item?.code).filter(Boolean);
return Array.from(new Set(codes)).sort().join('|'); return Array.from(new Set(codes)).join('|');
} catch (e) { } catch (e) {
return ''; return '';
} }
@@ -1731,6 +1745,60 @@ export default function HomePage() {
storageHelper.setItem('groups', JSON.stringify(next)); storageHelper.setItem('groups', JSON.stringify(next));
}; };
const handleReorder = (oldIndex, newIndex) => {
const movedItem = displayFunds[oldIndex];
const targetItem = displayFunds[newIndex];
if (!movedItem || !targetItem) return;
if (currentTab === 'all' || currentTab === 'fav') {
const newFunds = [...funds];
const fromIndex = newFunds.findIndex(f => f.code === movedItem.code);
if (fromIndex === -1) return;
// Remove moved item
const [removed] = newFunds.splice(fromIndex, 1);
// Find target index in the array (after removal)
const toIndex = newFunds.findIndex(f => f.code === targetItem.code);
if (toIndex === -1) {
// If target not found (should not happen), put it back
newFunds.splice(fromIndex, 0, removed);
return;
}
if (oldIndex < newIndex) {
// Moving down, insert after target
newFunds.splice(toIndex + 1, 0, removed);
} else {
// Moving up, insert before target
newFunds.splice(toIndex, 0, removed);
}
setFunds(newFunds);
storageHelper.setItem('funds', JSON.stringify(newFunds));
} else {
const groupIndex = groups.findIndex(g => g.id === currentTab);
if (groupIndex > -1) {
const group = groups[groupIndex];
const newCodes = [...group.codes];
const fromIndex = newCodes.indexOf(movedItem.code);
const toIndex = newCodes.indexOf(targetItem.code);
if (fromIndex !== -1 && toIndex !== -1) {
newCodes.splice(fromIndex, 1);
newCodes.splice(toIndex, 0, movedItem.code);
const newGroups = [...groups];
newGroups[groupIndex] = { ...group, codes: newCodes };
setGroups(newGroups);
storageHelper.setItem('groups', JSON.stringify(newGroups));
}
}
}
};
// 按 code 去重,保留第一次出现的项,避免列表重复 // 按 code 去重,保留第一次出现的项,避免列表重复
const dedupeByCode = (list) => { const dedupeByCode = (list) => {
const seen = new Set(); const seen = new Set();
@@ -3743,6 +3811,8 @@ export default function HomePage() {
refreshing={refreshing} refreshing={refreshing}
currentTab={currentTab} currentTab={currentTab}
favorites={favorites} favorites={favorites}
sortBy={sortBy}
onReorder={handleReorder}
onRemoveFund={(row) => { onRemoveFund={(row) => {
if (refreshing) return; if (refreshing) return;
if (!row || !row.code) return; if (!row || !row.code) return;

71
package-lock.json generated
View File

@@ -10,6 +10,10 @@
"dependencies": { "dependencies": {
"@dicebear/collection": "^9.3.1", "@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1", "@dicebear/core": "^9.3.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/supabase-js": "^2.78.0", "@supabase/supabase-js": "^2.78.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
@@ -722,6 +726,73 @@
"@dicebear/core": "^9.0.0" "@dicebear/core": "^9.0.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "9.0.0",
"resolved": "https://registry.npmmirror.com/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",

View File

@@ -13,6 +13,10 @@
"dependencies": { "dependencies": {
"@dicebear/collection": "^9.3.1", "@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1", "@dicebear/core": "^9.3.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/supabase-js": "^2.78.0", "@supabase/supabase-js": "^2.78.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",