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",