feat:表格列排序

This commit is contained in:
hzm
2026-02-28 23:08:42 +08:00
parent 9e743e29f4
commit e0260f01ec
4 changed files with 396 additions and 9 deletions

View File

@@ -23,7 +23,25 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import ConfirmModal from './ConfirmModal';
import { DragIcon, ExitIcon, ResetIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
import PcTableSettingModal from './PcTableSettingModal';
import { DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
const NON_FROZEN_COLUMN_IDS = [
'navOrEstimate',
'yesterdayChangePercent',
'estimateChangePercent',
'holdingAmount',
'todayProfit',
'holdingProfit',
];
const COLUMN_HEADERS = {
navOrEstimate: '净值/估值',
yesterdayChangePercent: '昨日涨跌幅',
estimateChangePercent: '估值涨跌幅',
holdingAmount: '持仓金额',
todayProfit: '当日收益',
holdingProfit: '持有收益',
};
const SortableRowContext = createContext({
setActivatorNodeRef: null,
@@ -168,6 +186,69 @@ export default function PcFundTable({
} catch { }
};
const getStoredColumnOrder = () => {
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?.pcTableColumnOrder;
if (!Array.isArray(order) || order.length === 0) return null;
const valid = order.filter((id) => NON_FROZEN_COLUMN_IDS.includes(id));
const missing = NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
return [...valid, ...missing];
} catch {
return null;
}
};
const persistColumnOrder = (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, pcTableColumnOrder: nextOrder }
: { pcTableColumnOrder: nextOrder };
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
} catch { }
};
const getStoredColumnVisibility = () => {
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?.pcTableColumnVisibility;
if (!visibility || typeof visibility !== 'object') return null;
const normalized = {};
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 persistColumnVisibility = (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, pcTableColumnVisibility: nextVisibility }
: { pcTableColumnVisibility: nextVisibility };
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
} catch { }
};
const [columnSizing, setColumnSizing] = useState(() => {
const stored = getStoredColumnSizing();
if (stored.actions) {
@@ -176,12 +257,45 @@ export default function PcFundTable({
}
return stored;
});
const [columnOrder, setColumnOrder] = useState(() => getStoredColumnOrder() ?? [...NON_FROZEN_COLUMN_IDS]);
const [columnVisibility, setColumnVisibility] = useState(() => {
const stored = getStoredColumnVisibility();
if (stored) return stored;
const allVisible = {};
NON_FROZEN_COLUMN_IDS.forEach((id) => {
allVisible[id] = true;
});
return allVisible;
});
const [settingModalOpen, setSettingModalOpen] = useState(false);
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const handleResetSizing = () => {
setColumnSizing({});
persistColumnSizing({});
setResetConfirmOpen(false);
};
const handleResetColumnOrder = () => {
const defaultOrder = [...NON_FROZEN_COLUMN_IDS];
setColumnOrder(defaultOrder);
persistColumnOrder(defaultOrder);
};
const handleResetColumnVisibility = () => {
const allVisible = {};
NON_FROZEN_COLUMN_IDS.forEach((id) => {
allVisible[id] = true;
});
setColumnVisibility(allVisible);
persistColumnVisibility(allVisible);
};
const handleToggleColumnVisibility = (columnId, visible) => {
setColumnVisibility((prev = {}) => {
const next = { ...prev, [columnId]: visible };
persistColumnVisibility(next);
return next;
});
};
const onRemoveFundRef = useRef(onRemoveFund);
const onToggleFavoriteRef = useRef(onToggleFavorite);
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
@@ -463,12 +577,12 @@ export default function PcFundTable({
className="icon-button"
onClick={(e) => {
e.stopPropagation?.();
setResetConfirmOpen(true);
setSettingModalOpen(true);
}}
title="重置列宽"
title="个性化设置"
style={{ border: 'none', width: '24px', height: '24px', backgroundColor: 'transparent', color: 'var(--text)' }}
>
<ResetIcon width="14" height="14" />
<SettingsIcon width="14" height="14" />
</button>
</div>
),
@@ -531,6 +645,22 @@ export default function PcFundTable({
},
state: {
columnSizing,
columnOrder,
columnVisibility,
},
onColumnOrderChange: (updater) => {
setColumnOrder((prev) => {
const next = typeof updater === 'function' ? updater(prev) : prev;
persistColumnOrder(next);
return next;
});
},
onColumnVisibilityChange: (updater) => {
setColumnVisibility((prev = {}) => {
const next = typeof updater === 'function' ? updater(prev) : (updater || {});
persistColumnVisibility(next);
return next;
});
},
initialState: {
columnPinning: {
@@ -752,6 +882,20 @@ export default function PcFundTable({
confirmText="重置"
/>
)}
<PcTableSettingModal
open={settingModalOpen}
onClose={() => setSettingModalOpen(false)}
columns={columnOrder.map((id) => ({ id, header: COLUMN_HEADERS[id] ?? id }))}
onColumnReorder={(newOrder) => {
setColumnOrder(newOrder);
persistColumnOrder(newOrder);
}}
columnVisibility={columnVisibility}
onToggleColumnVisibility={handleToggleColumnVisibility}
onResetColumnOrder={handleResetColumnOrder}
onResetColumnVisibility={handleResetColumnVisibility}
onResetSizing={() => setResetConfirmOpen(true)}
/>
</div>
);
}

View File

@@ -1,6 +1,8 @@
'use client';
import { SettingsIcon } from './Icons';
import { useEffect, useState } from 'react';
import ConfirmModal from './ConfirmModal';
import { ResetIcon, SettingsIcon } from './Icons';
export default function SettingsModal({
onClose,
@@ -10,15 +12,38 @@ export default function SettingsModal({
exportLocalData,
importFileRef,
handleImportFileChange,
importMsg
importMsg,
isMobile,
containerWidth = 1200,
setContainerWidth,
onResetContainerWidth,
}) {
const [sliderDragging, setSliderDragging] = useState(false);
const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false);
useEffect(() => {
if (!sliderDragging) return;
const onPointerUp = () => setSliderDragging(false);
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerUp);
return () => {
document.removeEventListener('pointerup', onPointerUp);
document.removeEventListener('pointercancel', onPointerUp);
};
}, [sliderDragging]);
return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={onClose}>
<div
className={`modal-overlay ${sliderDragging ? 'modal-overlay-translucent' : ''}`}
role="dialog"
aria-modal="true"
aria-label="设置"
onClick={onClose}
>
<div className="glass card modal" onClick={(e) => e.stopPropagation()}>
<div className="title" style={{ marginBottom: 12 }}>
<SettingsIcon width="20" height="20" />
<span>设置</span>
<span className="muted">配置刷新频率</span>
</div>
<div className="form-group" style={{ marginBottom: 16 }}>
@@ -53,6 +78,52 @@ export default function SettingsModal({
)}
</div>
{!isMobile && setContainerWidth && (
<div className="form-group" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<div className="muted" style={{ fontSize: '0.8rem' }}>页面宽度</div>
{onResetContainerWidth && (
<button
type="button"
className="icon-button"
onClick={() => setResetWidthConfirmOpen(true)}
title="重置页面宽度"
style={{
border: 'none',
width: '24px',
height: '24px',
padding: 0,
backgroundColor: 'transparent',
color: 'var(--muted)',
}}
>
<ResetIcon width="14" height="14" />
</button>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
type="range"
min={600}
max={2000}
step={10}
value={Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}
onChange={(e) => setContainerWidth(Number(e.target.value))}
onPointerDown={() => setSliderDragging(true)}
className="page-width-slider"
style={{
flex: 1,
height: 6,
accentColor: 'var(--primary)',
}}
/>
<span className="muted" style={{ fontSize: '0.8rem', minWidth: 48 }}>
{Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}px
</span>
</div>
</div>
)}
<div className="form-group" style={{ marginBottom: 16 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
<div className="row" style={{ gap: 8 }}>
@@ -80,6 +151,18 @@ export default function SettingsModal({
<button className="button" onClick={saveSettings} disabled={tempSeconds < 30}>保存并关闭</button>
</div>
</div>
{resetWidthConfirmOpen && onResetContainerWidth && (
<ConfirmModal
title="重置页面宽度"
message="是否重置页面宽度为默认值 1200px"
onConfirm={() => {
onResetContainerWidth();
setResetWidthConfirmOpen(false);
}}
onCancel={() => setResetWidthConfirmOpen(false)}
confirmText="重置"
/>
)}
</div>
);
}

View File

@@ -92,6 +92,43 @@ body::before {
padding: 24px;
}
.page-width-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: rgba(148, 163, 184, 0.4);
outline: none;
}
.page-width-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
transition: transform 0.15s;
}
.page-width-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.page-width-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.glass {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
border: 1px solid var(--border);
@@ -1582,6 +1619,12 @@ input[type="number"] {
align-items: center;
justify-content: center;
z-index: 60;
transition: background 0.2s ease, backdrop-filter 0.2s ease;
}
.modal-overlay-translucent {
background: rgba(2, 6, 23, 0.15);
backdrop-filter: blur(1px);
}
.modal {
@@ -1590,6 +1633,88 @@ input[type="number"] {
padding: 16px;
}
/* PC 表格设置侧弹框 */
.pc-table-setting-overlay {
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: stretch;
justify-content: flex-end;
}
.pc-table-setting-drawer {
width: 360px;
max-width: 90vw;
display: flex;
flex-direction: column;
border-radius: 16px 0 0 16px;
border: 1px solid var(--border);
border-right: none;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
}
.pc-table-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;
}
.pc-table-setting-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.pc-table-setting-subtitle {
margin: 0 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.pc-table-setting-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
margin: 0;
list-style: none;
}
.pc-table-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;
}
.pc-table-setting-item:active {
cursor: grabbing;
}
.pc-table-setting-item:hover {
background: rgba(255, 255, 255, 0.06);
border-color: var(--primary);
}
.pc-table-column-switch .dca-toggle-track {
flex-shrink: 0;
}
[data-theme="light"] .pc-table-setting-drawer .dca-toggle-thumb {
background: #fff;
}
/* 定投按钮:暗色主题 */
.dca-btn {
background: rgba(34, 211, 238, 0.12);

View File

@@ -311,6 +311,21 @@ export default function HomePage() {
const [refreshMs, setRefreshMs] = useState(60000);
const [settingsOpen, setSettingsOpen] = useState(false);
const [tempSeconds, setTempSeconds] = useState(60);
const [containerWidth, setContainerWidth] = useState(1200);
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const raw = window.localStorage.getItem('customSettings');
if (!raw) return;
const parsed = JSON.parse(raw);
const w = parsed?.pcContainerWidth;
const num = Number(w);
if (Number.isFinite(num)) {
setContainerWidth(Math.min(2000, Math.max(600, num)));
}
} catch { }
}, []);
// 全局刷新状态
const [refreshing, setRefreshing] = useState(false);
@@ -2539,9 +2554,25 @@ export default function HomePage() {
const ms = Math.max(30, Number(tempSeconds)) * 1000;
setRefreshMs(ms);
storageHelper.setItem('refreshMs', String(ms));
const w = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
setContainerWidth(w);
try {
const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {};
window.localStorage.setItem('customSettings', JSON.stringify({ ...parsed, pcContainerWidth: w }));
} catch { }
setSettingsOpen(false);
};
const handleResetContainerWidth = () => {
setContainerWidth(1200);
try {
const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {};
window.localStorage.setItem('customSettings', JSON.stringify({ ...parsed, pcContainerWidth: 1200 }));
} catch { }
};
const importFileRef = useRef(null);
const [importMsg, setImportMsg] = useState('');
@@ -3301,7 +3332,7 @@ export default function HomePage() {
};
return (
<div className="container content">
<div className="container content" style={{ width: containerWidth }}>
<AnimatePresence>
{showThemeTransition && (
<motion.div
@@ -4797,6 +4828,10 @@ export default function HomePage() {
importFileRef={importFileRef}
handleImportFileChange={handleImportFileChange}
importMsg={importMsg}
isMobile={isMobile}
containerWidth={containerWidth}
setContainerWidth={setContainerWidth}
onResetContainerWidth={handleResetContainerWidth}
/>
)}