From e7192987f46f0020d1e544a9a58e063c2c02389b Mon Sep 17 00:00:00 2001
From: hzm <934585316@qq.com>
Date: Fri, 27 Feb 2026 20:27:08 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20PC=20=E7=AB=AF=E8=A1=A8=E6=A0=BC?=
=?UTF-8?q?=E6=8B=96=E6=8B=BD=E6=8E=92=E5=BA=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/components/PcFundTable.jsx | 332 ++++++++++++++++++++++-----------
app/globals.css | 2 +-
app/page.jsx | 82 +++++++-
package-lock.json | 71 +++++++
package.json | 4 +
5 files changed, 375 insertions(+), 116 deletions(-)
diff --git a/app/components/PcFundTable.jsx b/app/components/PcFundTable.jsx
index a3f4e61..a573bab 100644
--- a/app/components/PcFundTable.jsx
+++ b/app/components/PcFundTable.jsx
@@ -1,14 +1,75 @@
'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 {
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 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 (
+
+
+ {children}
+
+
+ );
+}
/**
* PC 端基金列表表格组件(基于 @tanstack/react-table)
@@ -45,7 +106,39 @@ export default function PcFundTable({
onHoldingAmountClick,
onHoldingProfitClick,
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 = () => {
if (typeof window === 'undefined') return {};
try {
@@ -108,6 +201,65 @@ export default function PcFundTable({
onHoldingAmountClick,
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 (
+
+ {sortBy === 'default' && (
+
+ )}
+ {isGroupTab ? (
+
+ ) : (
+
+ )}
+
+
+ {info.getValue() ?? '—'}
+
+ {code ? #{code} : null}
+
+
+ );
+ };
+
const columns = useMemo(
() => [
{
@@ -116,49 +268,7 @@ export default function PcFundTable({
size: 265,
minSize: 140,
enablePinning: true,
- cell: (info) => {
- const original = info.row.original || {};
- const code = original.code;
- const isUpdated = original.isUpdated;
- const isFavorites = favorites?.has?.(code);
- const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
- return (
-
- {isGroupTab ? (
-
- ) : (
-
- )}
-
-
- {info.getValue() ?? '—'}
-
- {code ? #{code} : null}
-
-
- );
- },
+ cell: (info) => ,
meta: {
align: 'left',
cellClassName: 'name-cell',
@@ -188,11 +298,11 @@ export default function PcFundTable({
const date = original.yesterdayDate ?? '-';
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
return (
-
+
{info.getValue() ?? '—'}
-
+
{date}
@@ -215,11 +325,11 @@ export default function PcFundTable({
const time = original.estimateTime ?? '-';
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
return (
-
+
{info.getValue() ?? '—'}
-
+
{time}
@@ -247,12 +357,12 @@ export default function PcFundTable({
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '12px', cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation?.();
- onHoldingAmountClickRef.current?.(original, { hasHolding: false });
+ onHoldingAmountClickRef.current?.(original, { hasHolding: false });
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
- onHoldingAmountClickRef.current?.(original, { hasHolding: false });
+ onHoldingAmountClickRef.current?.(original, { hasHolding: false });
}
}}
>
@@ -355,7 +465,7 @@ export default function PcFundTable({
title="重置列宽"
style={{ border: 'none', width: '24px', height: '24px', backgroundColor: 'transparent', color: 'var(--text)' }}
>
-
+
),
@@ -399,7 +509,7 @@ export default function PcFundTable({
},
},
],
- [currentTab, favorites, refreshing],
+ [currentTab, favorites, refreshing, sortBy],
);
const table = useReactTable({
@@ -551,15 +661,14 @@ export default function PcFundTable({
{header.isPlaceholder
? null
: flexRender(
- header.column.columnDef.header,
- header.getContext(),
- )}
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
);
@@ -568,56 +677,61 @@ export default function PcFundTable({
)}
{/* 表体 */}
-
- {table.getRowModel().rows.map((row) => (
-
-
- {row.getVisibleCells().map((cell) => {
- const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
- const isNameColumn = columnId === 'fundName';
- const rightAlignedColumns = new Set([
- 'yesterdayChangePercent',
- 'estimateChangePercent',
- 'holdingAmount',
- 'todayProfit',
- 'holdingProfit',
- ]);
- const align = isNameColumn
- ? ''
- : rightAlignedColumns.has(columnId)
- ? 'text-right'
- : 'text-center';
- const cellClassName =
- (cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
- const style = getCommonPinningStyles(cell.column, false);
- return (
-
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext(),
- )}
-
- );
- })}
-
-
- ))}
-
+
+ item.code)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {table.getRowModel().rows.map((row) => (
+
+
+ {row.getVisibleCells().map((cell) => {
+ const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
+ const isNameColumn = columnId === 'fundName';
+ const rightAlignedColumns = new Set([
+ 'yesterdayChangePercent',
+ 'estimateChangePercent',
+ 'holdingAmount',
+ 'todayProfit',
+ 'holdingProfit',
+ ]);
+ const align = isNameColumn
+ ? ''
+ : rightAlignedColumns.has(columnId)
+ ? 'text-right'
+ : 'text-center';
+ const cellClassName =
+ (cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
+ const style = getCommonPinningStyles(cell.column, false);
+ return (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ );
+ })}
+
+
+ ))}
+
+
+
{table.getRowModel().rows.length === 0 && (
diff --git a/app/globals.css b/app/globals.css
index 69f4106..e598067 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1133,7 +1133,7 @@ input[type="number"] {
}
.tab {
- padding: 0 20px;
+ padding: 0 10px;
border-radius: 8px;
border: none;
background: transparent;
diff --git a/app/page.jsx b/app/page.jsx
index 4e155b4..4d38773 100644
--- a/app/page.jsx
+++ b/app/page.jsx
@@ -640,14 +640,27 @@ export default function HomePage() {
// 过滤和排序后的基金列表
const displayFunds = useMemo(
- () => funds
- .filter(f => {
+ () => {
+ let filtered = funds.filter(f => {
if (currentTab === 'all') return true;
if (currentTab === 'fav') return favorites.has(f.code);
const group = groups.find(g => g.id === currentTab);
return group ? group.codes.includes(f.code) : true;
- })
- .sort((a, b) => {
+ });
+
+ if (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') {
const valA = isNumber(a.estGszzl) ? a.estGszzl : (a.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 0;
- }),
+ });
+ },
[funds, currentTab, favorites, groups, sortBy, sortOrder, holdings, getHoldingProfit],
);
@@ -1401,7 +1415,7 @@ export default function HomePage() {
const list = JSON.parse(value || '[]');
if (!Array.isArray(list)) return '';
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) {
return '';
}
@@ -1731,6 +1745,60 @@ export default function HomePage() {
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 去重,保留第一次出现的项,避免列表重复
const dedupeByCode = (list) => {
const seen = new Set();
@@ -3743,6 +3811,8 @@ export default function HomePage() {
refreshing={refreshing}
currentTab={currentTab}
favorites={favorites}
+ sortBy={sortBy}
+ onReorder={handleReorder}
onRemoveFund={(row) => {
if (refreshing) return;
if (!row || !row.code) return;
diff --git a/package-lock.json b/package-lock.json
index c7d7a48..0c523b5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,10 @@
"dependencies": {
"@dicebear/collection": "^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",
"@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.1",
@@ -722,6 +726,73 @@
"@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": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
diff --git a/package.json b/package.json
index a0f5e94..d589a9d 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,10 @@
"dependencies": {
"@dicebear/collection": "^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",
"@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.1",