feat: PC 端表格拖拽排序
This commit is contained in:
@@ -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,6 +201,65 @@ export default function PcFundTable({
|
|||||||
onHoldingAmountClick,
|
onHoldingAmountClick,
|
||||||
onHoldingProfitClick,
|
onHoldingProfitClick,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const FundNameCell = ({ info }) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const code = original.code;
|
||||||
|
const isUpdated = original.isUpdated;
|
||||||
|
const isFavorites = favorites?.has?.(code);
|
||||||
|
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
|
||||||
|
const rowContext = useContext(SortableRowContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 ? (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -116,49 +268,7 @@ export default function PcFundTable({
|
|||||||
size: 265,
|
size: 265,
|
||||||
minSize: 140,
|
minSize: 140,
|
||||||
enablePinning: true,
|
enablePinning: true,
|
||||||
cell: (info) => {
|
cell: (info) => <FundNameCell info={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: {
|
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>
|
||||||
@@ -247,12 +357,12 @@ export default function PcFundTable({
|
|||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '12px', cursor: 'pointer' }}
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '12px', cursor: 'pointer' }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation?.();
|
e.stopPropagation?.();
|
||||||
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -355,7 +465,7 @@ export default function PcFundTable({
|
|||||||
title="重置列宽"
|
title="重置列宽"
|
||||||
style={{ border: 'none', width: '24px', height: '24px', backgroundColor: 'transparent', color: 'var(--text)' }}
|
style={{ border: 'none', width: '24px', height: '24px', backgroundColor: 'transparent', color: 'var(--text)' }}
|
||||||
>
|
>
|
||||||
<ResetIcon width="14" height="14" />
|
<ResetIcon width="14" height="14" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -399,7 +509,7 @@ export default function PcFundTable({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[currentTab, favorites, refreshing],
|
[currentTab, favorites, refreshing, sortBy],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -551,15 +661,14 @@ export default function PcFundTable({
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
<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,56 +677,61 @@ export default function PcFundTable({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 表体 */}
|
{/* 表体 */}
|
||||||
<AnimatePresence mode="popLayout">
|
<DndContext
|
||||||
{table.getRowModel().rows.map((row) => (
|
sensors={sensors}
|
||||||
<motion.div
|
collisionDetection={closestCenter}
|
||||||
key={row.original.code || row.id}
|
onDragStart={handleDragStart}
|
||||||
className="table-row-wrapper"
|
onDragEnd={handleDragEnd}
|
||||||
layout="position"
|
onDragCancel={handleDragCancel}
|
||||||
initial={{ opacity: 0 }}
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
animate={{ opacity: 1 }}
|
>
|
||||||
exit={{ opacity: 0 }}
|
<SortableContext
|
||||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
items={data.map((item) => item.code)}
|
||||||
style={{ position: 'relative' }}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div
|
<AnimatePresence mode="popLayout">
|
||||||
className="table-row table-row-scroll"
|
{table.getRowModel().rows.map((row) => (
|
||||||
>
|
<SortableRow key={row.original.code || row.id} row={row} isTableDragging={!!activeId} disabled={sortBy !== 'default'}>
|
||||||
{row.getVisibleCells().map((cell) => {
|
<div
|
||||||
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
|
className="table-row table-row-scroll"
|
||||||
const isNameColumn = columnId === 'fundName';
|
>
|
||||||
const rightAlignedColumns = new Set([
|
{row.getVisibleCells().map((cell) => {
|
||||||
'yesterdayChangePercent',
|
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
|
||||||
'estimateChangePercent',
|
const isNameColumn = columnId === 'fundName';
|
||||||
'holdingAmount',
|
const rightAlignedColumns = new Set([
|
||||||
'todayProfit',
|
'yesterdayChangePercent',
|
||||||
'holdingProfit',
|
'estimateChangePercent',
|
||||||
]);
|
'holdingAmount',
|
||||||
const align = isNameColumn
|
'todayProfit',
|
||||||
? ''
|
'holdingProfit',
|
||||||
: rightAlignedColumns.has(columnId)
|
]);
|
||||||
? 'text-right'
|
const align = isNameColumn
|
||||||
: 'text-center';
|
? ''
|
||||||
const cellClassName =
|
: rightAlignedColumns.has(columnId)
|
||||||
(cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
|
? 'text-right'
|
||||||
const style = getCommonPinningStyles(cell.column, false);
|
: 'text-center';
|
||||||
return (
|
const cellClassName =
|
||||||
<div
|
(cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
|
||||||
key={cell.id}
|
const style = getCommonPinningStyles(cell.column, false);
|
||||||
className={`table-cell ${align} ${cellClassName}`}
|
return (
|
||||||
style={style}
|
<div
|
||||||
>
|
key={cell.id}
|
||||||
{flexRender(
|
className={`table-cell ${align} ${cellClassName}`}
|
||||||
cell.column.columnDef.cell,
|
style={style}
|
||||||
cell.getContext(),
|
>
|
||||||
)}
|
{flexRender(
|
||||||
</div>
|
cell.column.columnDef.cell,
|
||||||
);
|
cell.getContext(),
|
||||||
})}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
);
|
||||||
))}
|
})}
|
||||||
</AnimatePresence>
|
</div>
|
||||||
|
</SortableRow>
|
||||||
|
))}
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
82
app/page.jsx
82
app/page.jsx
@@ -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
71
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user