feat:个性化数据往云端同步

This commit is contained in:
hzm
2026-03-01 16:49:46 +08:00
parent 2a406be0b1
commit e7661e7b38
5 changed files with 187 additions and 25 deletions

View File

@@ -0,0 +1,87 @@
'use client';
import { useLayoutEffect, useRef } from 'react';
/**
* 根据容器宽度动态缩小字体,使内容不溢出。
* 使用 ResizeObserver 监听容器宽度,内容超出时按比例缩小 fontSize不低于 minFontSize。
*
* @param {Object} props
* @param {React.ReactNode} props.children - 要显示的文本(会单行、不换行)
* @param {number} [props.maxFontSize=14] - 最大字号px
* @param {number} [props.minFontSize=10] - 最小字号px再窄也不低于此值
* @param {string} [props.className] - 外层容器 className
* @param {Object} [props.style] - 外层容器 style宽度由父级决定建议父级有明确宽度
* @param {string} [props.as='span'] - 外层容器标签 'span' | 'div'
*/
export default function FitText({
children,
maxFontSize = 14,
minFontSize = 10,
className,
style = {},
as: Tag = 'span',
}) {
const containerRef = useRef(null);
const contentRef = useRef(null);
const adjust = () => {
const container = containerRef.current;
const content = contentRef.current;
if (!container || !content) return;
const containerWidth = container.clientWidth;
if (containerWidth <= 0) return;
// 先恢复到最大字号再测量,确保在「未缩放」状态下取到真实内容宽度
content.style.fontSize = `${maxFontSize}px`;
const run = () => {
const contentWidth = content.scrollWidth;
if (contentWidth <= 0) return;
let size = maxFontSize;
if (contentWidth > containerWidth) {
size = (containerWidth / contentWidth) * maxFontSize;
size = Math.max(minFontSize, Math.min(maxFontSize, size));
}
content.style.fontSize = `${size}px`;
};
requestAnimationFrame(run);
};
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
adjust();
const ro = new ResizeObserver(adjust);
ro.observe(container);
return () => ro.disconnect();
}, [children, maxFontSize, minFontSize]);
return (
<Tag
ref={containerRef}
className={className}
style={{
display: 'block',
width: '100%',
overflow: 'hidden',
...style,
}}
>
<span
ref={contentRef}
style={{
display: 'inline-block',
whiteSpace: 'nowrap',
fontWeight: 'inherit',
fontSize: `${maxFontSize}px`,
}}
>
{children}
</span>
</Tag>
);
}

View File

@@ -22,6 +22,7 @@ import {
useSortable, useSortable,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import FitText from './FitText';
import MobileSettingModal from './MobileSettingModal'; import MobileSettingModal from './MobileSettingModal';
import { ExitIcon, SettingsIcon, StarIcon } from './Icons'; import { ExitIcon, SettingsIcon, StarIcon } from './Icons';
@@ -100,6 +101,7 @@ export default function MobileFundTable({
refreshing = false, refreshing = false,
sortBy = 'default', sortBy = 'default',
onReorder, onReorder,
onCustomSettingsChange,
}) { }) {
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -164,6 +166,7 @@ export default function MobileFundTable({
? { ...parsed, mobileTableColumnOrder: nextOrder } ? { ...parsed, mobileTableColumnOrder: nextOrder }
: { mobileTableColumnOrder: nextOrder }; : { mobileTableColumnOrder: nextOrder };
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings)); window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
onCustomSettingsChange?.();
} catch {} } catch {}
}; };
const getStoredMobileColumnVisibility = () => { const getStoredMobileColumnVisibility = () => {
@@ -194,6 +197,7 @@ export default function MobileFundTable({
? { ...parsed, mobileTableColumnVisibility: nextVisibility } ? { ...parsed, mobileTableColumnVisibility: nextVisibility }
: { mobileTableColumnVisibility: nextVisibility }; : { mobileTableColumnVisibility: nextVisibility };
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings)); window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
onCustomSettingsChange?.();
} catch {} } catch {}
}; };
@@ -352,7 +356,7 @@ export default function MobileFundTable({
</div> </div>
), ),
cell: (info) => <MobileFundNameCell info={info} />, cell: (info) => <MobileFundNameCell info={info} />,
meta: { align: 'left', cellClassName: 'name-cell' }, meta: { align: 'left', cellClassName: 'name-cell', width: 140 },
}, },
{ {
accessorKey: 'yesterdayChangePercent', accessorKey: 'yesterdayChangePercent',
@@ -367,11 +371,11 @@ export default function MobileFundTable({
<span className={cls} style={{ fontWeight: 700 }}> <span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'} {info.getValue() ?? '—'}
</span> </span>
<span className="muted" style={{ fontSize: '11px' }}>{date}</span> <span className="muted" style={{ fontSize: '10px' }}>{date}</span>
</div> </div>
); );
}, },
meta: { align: 'right', cellClassName: 'change-cell' }, meta: { align: 'right', cellClassName: 'change-cell', width: 72 },
}, },
{ {
accessorKey: 'estimateChangePercent', accessorKey: 'estimateChangePercent',
@@ -387,11 +391,11 @@ export default function MobileFundTable({
<span className={cls} style={{ fontWeight: 700 }}> <span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'} {info.getValue() ?? '—'}
</span> </span>
<span className="muted" style={{ fontSize: '11px' }}>{time}</span> <span className="muted" style={{ fontSize: '10px' }}>{time}</span>
</div> </div>
); );
}, },
meta: { align: 'right', cellClassName: 'est-change-cell' }, meta: { align: 'right', cellClassName: 'est-change-cell', width: 80 },
}, },
{ {
accessorKey: 'todayProfit', accessorKey: 'todayProfit',
@@ -402,12 +406,14 @@ export default function MobileFundTable({
const hasProfit = value != null; const hasProfit = value != null;
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted'; const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
return ( return (
<span className={cls} style={{ fontWeight: 700 }}> <span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
{hasProfit ? (info.getValue() ?? '') : ''} <FitText maxFontSize={14} minFontSize={10}>
{hasProfit ? (info.getValue() ?? '') : ''}
</FitText>
</span> </span>
); );
}, },
meta: { align: 'right', cellClassName: 'profit-cell' }, meta: { align: 'right', cellClassName: 'profit-cell', width: 80 },
}, },
{ {
accessorKey: 'holdingProfit', accessorKey: 'holdingProfit',
@@ -420,20 +426,22 @@ export default function MobileFundTable({
return ( return (
<div <div
title="点击切换金额/百分比" title="点击切换金额/百分比"
style={{ cursor: hasTotal ? 'pointer' : 'default' }} style={{ cursor: hasTotal ? 'pointer' : 'default', width: '100%' }}
onClick={(e) => { onClick={(e) => {
if (!hasTotal) return; if (!hasTotal) return;
e.stopPropagation?.(); e.stopPropagation?.();
onHoldingProfitClickRef.current?.(original); onHoldingProfitClickRef.current?.(original);
}} }}
> >
<span className={cls} style={{ fontWeight: 700 }}> <span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
{hasTotal ? (info.getValue() ?? '') : ''} <FitText maxFontSize={14} minFontSize={10}>
{hasTotal ? (info.getValue() ?? '') : ''}
</FitText>
</span> </span>
</div> </div>
); );
}, },
meta: { align: 'right', cellClassName: 'holding-cell' }, meta: { align: 'right', cellClassName: 'holding-cell', width: 80 },
}, },
], ],
[currentTab, favorites, refreshing] [currentTab, favorites, refreshing]
@@ -474,6 +482,18 @@ export default function MobileFundTable({
const headerGroup = table.getHeaderGroups()[0]; const headerGroup = table.getHeaderGroups()[0];
const LAST_COLUMN_EXTRA = 12;
const mobileGridLayout = (() => {
if (!headerGroup?.headers?.length) return { gridTemplateColumns: '', minWidth: undefined };
const gap = 12;
const widths = headerGroup.headers.map((h) => h.column.columnDef.meta?.width ?? 80);
if (widths.length > 0) widths[widths.length - 1] += LAST_COLUMN_EXTRA;
return {
gridTemplateColumns: widths.map((w) => `${w}px`).join(' '),
minWidth: widths.reduce((a, b) => a + b, 0) + (widths.length - 1) * gap,
};
})();
const getPinClass = (columnId, isHeader) => { const getPinClass = (columnId, isHeader) => {
if (columnId === 'fundName') return isHeader ? 'table-header-cell-pin-left' : 'table-cell-pin-left'; if (columnId === 'fundName') return isHeader ? 'table-header-cell-pin-left' : 'table-cell-pin-left';
return ''; return '';
@@ -487,17 +507,25 @@ export default function MobileFundTable({
return ( return (
<div className="mobile-fund-table"> <div className="mobile-fund-table">
<div className="mobile-fund-table-scroll"> <div
className="mobile-fund-table-scroll"
style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
>
{headerGroup && ( {headerGroup && (
<div className="table-header-row mobile-fund-table-header"> <div
{headerGroup.headers.map((header) => { className="table-header-row mobile-fund-table-header"
style={mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : undefined}
>
{headerGroup.headers.map((header, headerIndex) => {
const columnId = header.column.id; const columnId = header.column.id;
const pinClass = getPinClass(columnId, true); const pinClass = getPinClass(columnId, true);
const alignClass = getAlignClass(columnId); const alignClass = getAlignClass(columnId);
const isLastColumn = headerIndex === headerGroup.headers.length - 1;
return ( return (
<div <div
key={header.id} key={header.id}
className={`table-header-cell ${alignClass} ${pinClass}`} className={`table-header-cell ${alignClass} ${pinClass}`}
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
> >
{header.isPlaceholder {header.isPlaceholder
? null ? null
@@ -536,18 +564,21 @@ export default function MobileFundTable({
background: 'var(--bg)', background: 'var(--bg)',
position: 'relative', position: 'relative',
zIndex: 1, zIndex: 1,
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
}} }}
{...(sortBy === 'default' ? listeners : {})} {...(sortBy === 'default' ? listeners : {})}
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell, cellIndex) => {
const columnId = cell.column.id; const columnId = cell.column.id;
const pinClass = getPinClass(columnId, false); const pinClass = getPinClass(columnId, false);
const alignClass = getAlignClass(columnId); const alignClass = getAlignClass(columnId);
const cellClassName = cell.column.columnDef.meta?.cellClassName || ''; const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
const isLastColumn = cellIndex === row.getVisibleCells().length - 1;
return ( return (
<div <div
key={cell.id} key={cell.id}
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`} className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</div> </div>

View File

@@ -126,6 +126,7 @@ export default function PcFundTable({
refreshing = false, refreshing = false,
sortBy = 'default', sortBy = 'default',
onReorder, onReorder,
onCustomSettingsChange,
}) { }) {
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -183,6 +184,7 @@ export default function PcFundTable({
? { ...parsed, pcTableColumns: nextSizing } ? { ...parsed, pcTableColumns: nextSizing }
: { pcTableColumns: nextSizing }; : { pcTableColumns: nextSizing };
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings)); window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
onCustomSettingsChange?.();
} catch { } } catch { }
}; };
@@ -212,6 +214,7 @@ export default function PcFundTable({
? { ...parsed, pcTableColumnOrder: nextOrder } ? { ...parsed, pcTableColumnOrder: nextOrder }
: { pcTableColumnOrder: nextOrder }; : { pcTableColumnOrder: nextOrder };
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings)); window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
onCustomSettingsChange?.();
} catch { } } catch { }
}; };
@@ -246,6 +249,7 @@ export default function PcFundTable({
? { ...parsed, pcTableColumnVisibility: nextVisibility } ? { ...parsed, pcTableColumnVisibility: nextVisibility }
: { pcTableColumnVisibility: nextVisibility }; : { pcTableColumnVisibility: nextVisibility };
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings)); window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
onCustomSettingsChange?.();
} catch { } } catch { }
}; };

View File

@@ -1428,24 +1428,27 @@ input[type="number"] {
} }
.mobile-fund-table-scroll { .mobile-fund-table-scroll {
min-width: 520px; /* min-width 由 MobileFundTable 根据 columns meta.width 动态设置 */
} }
.mobile-fund-table .table-header-row { .mobile-fund-table .table-header-row {
display: grid; display: grid;
grid-template-columns: 140px 1fr 1fr 1.2fr 1.2fr; /* grid-template-columns 由 MobileFundTable 根据当前列顺序动态设置 */
padding-top: 0 !important; padding-top: 0 !important;
padding-bottom: 0 !important; padding-bottom: 0 !important;
padding-left: 0 !important; padding-left: 0 !important;
} }
.mobile-fund-table .table-header-row .table-header-cell { .mobile-fund-table .table-header-row .table-header-cell {
padding-top: 16px; padding-top: 8px;
padding-bottom: 16px; padding-bottom: 8px;
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
} }
.mobile-fund-table .table-row { .mobile-fund-table .table-row {
grid-template-columns: 140px 1fr 1fr 1.2fr 1.2fr;
grid-template-areas: unset; grid-template-areas: unset;
gap: 12px; gap: 12px;
padding-top: 0 !important; padding-top: 0 !important;
@@ -1468,6 +1471,9 @@ input[type="number"] {
.mobile-fund-table .table-row .table-cell { .mobile-fund-table .table-row .table-cell {
padding-top: 12px; padding-top: 12px;
padding-bottom: 12px; padding-bottom: 12px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
} }
/* 基金名称列固定左侧:右侧阴影突出固定列,覆盖行 border-bottom */ /* 基金名称列固定左侧:右侧阴影突出固定列,覆盖行 border-bottom */

View File

@@ -1502,7 +1502,7 @@ export default function HomePage() {
const storageHelper = useMemo(() => { const storageHelper = useMemo(() => {
// 仅以下 key 参与云端同步fundValuationTimeseries 不同步到云端(测试中功能,暂不同步) // 仅以下 key 参与云端同步fundValuationTimeseries 不同步到云端(测试中功能,暂不同步)
const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'transactions', 'viewMode', 'dcaPlans']); const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'transactions', 'viewMode', 'dcaPlans', 'customSettings']);
const triggerSync = (key, prevValue, nextValue) => { const triggerSync = (key, prevValue, nextValue) => {
if (keys.has(key)) { if (keys.has(key)) {
// 标记为脏数据 // 标记为脏数据
@@ -1549,7 +1549,7 @@ export default function HomePage() {
useEffect(() => { useEffect(() => {
// 仅以下 key 的变更会触发云端同步fundValuationTimeseries 不在其中 // 仅以下 key 的变更会触发云端同步fundValuationTimeseries 不在其中
const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode', 'dcaPlans']); const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode', 'dcaPlans', 'customSettings']);
const onStorage = (e) => { const onStorage = (e) => {
if (!e.key) return; if (!e.key) return;
if (e.key === 'localUpdatedAt') { if (e.key === 'localUpdatedAt') {
@@ -1570,6 +1570,18 @@ export default function HomePage() {
}; };
}, [getFundCodesSignature, scheduleSync]); }, [getFundCodesSignature, scheduleSync]);
const triggerCustomSettingsSync = useCallback(() => {
queueMicrotask(() => {
dirtyKeysRef.current.add('customSettings');
if (!skipSyncRef.current) {
const now = nowInTz().toISOString();
window.localStorage.setItem('localUpdatedAt', now);
setLastSyncTime(now);
}
scheduleSync();
});
}, [scheduleSync]);
const applyViewMode = useCallback((mode) => { const applyViewMode = useCallback((mode) => {
if (mode !== 'card' && mode !== 'list') return; if (mode !== 'card' && mode !== 'list') return;
setViewMode(mode); setViewMode(mode);
@@ -2561,6 +2573,7 @@ export default function HomePage() {
const raw = window.localStorage.getItem('customSettings'); const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {}; const parsed = raw ? JSON.parse(raw) : {};
window.localStorage.setItem('customSettings', JSON.stringify({ ...parsed, pcContainerWidth: w })); window.localStorage.setItem('customSettings', JSON.stringify({ ...parsed, pcContainerWidth: w }));
triggerCustomSettingsSync();
} catch { } } catch { }
setSettingsOpen(false); setSettingsOpen(false);
}; };
@@ -2571,6 +2584,7 @@ export default function HomePage() {
const raw = window.localStorage.getItem('customSettings'); const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {}; const parsed = raw ? JSON.parse(raw) : {};
window.localStorage.setItem('customSettings', JSON.stringify({ ...parsed, pcContainerWidth: 1200 })); window.localStorage.setItem('customSettings', JSON.stringify({ ...parsed, pcContainerWidth: 1200 }));
triggerCustomSettingsSync();
} catch { } } catch { }
}; };
@@ -2715,6 +2729,7 @@ export default function HomePage() {
}); });
const viewMode = payload.viewMode === 'list' ? 'list' : 'card'; const viewMode = payload.viewMode === 'list' ? 'list' : 'card';
const customSettings = isPlainObject(payload.customSettings) ? payload.customSettings : {};
return JSON.stringify({ return JSON.stringify({
funds: uniqueFundCodes, funds: uniqueFundCodes,
@@ -2727,7 +2742,8 @@ export default function HomePage() {
pendingTrades, pendingTrades,
transactions, transactions,
dcaPlans, dcaPlans,
viewMode viewMode,
customSettings
}); });
} }
@@ -2768,6 +2784,13 @@ export default function HomePage() {
if (!keys || keys.has('dcaPlans')) { if (!keys || keys.has('dcaPlans')) {
all.dcaPlans = JSON.parse(localStorage.getItem('dcaPlans') || '{}'); all.dcaPlans = JSON.parse(localStorage.getItem('dcaPlans') || '{}');
} }
if (!keys || keys.has('customSettings')) {
try {
all.customSettings = JSON.parse(localStorage.getItem('customSettings') || '{}');
} catch {
all.customSettings = {};
}
}
// 如果是全量收集keys 为 null进行完整的数据清洗和验证逻辑 // 如果是全量收集keys 为 null进行完整的数据清洗和验证逻辑
if (!keys) { if (!keys) {
@@ -2837,7 +2860,8 @@ export default function HomePage() {
pendingTrades: all.pendingTrades, pendingTrades: all.pendingTrades,
transactions: all.transactions, transactions: all.transactions,
dcaPlans: cleanedDcaPlans, dcaPlans: cleanedDcaPlans,
viewMode: all.viewMode viewMode: all.viewMode,
customSettings: isPlainObject(all.customSettings) ? all.customSettings : {}
}; };
} }
@@ -2858,6 +2882,7 @@ export default function HomePage() {
transactions: {}, transactions: {},
dcaPlans: {}, dcaPlans: {},
viewMode: 'card', viewMode: 'card',
customSettings: {},
exportedAt: nowInTz().toISOString() exportedAt: nowInTz().toISOString()
}; };
} }
@@ -2919,6 +2944,13 @@ export default function HomePage() {
setDcaPlans(nextDcaPlans); setDcaPlans(nextDcaPlans);
storageHelper.setItem('dcaPlans', JSON.stringify(nextDcaPlans)); storageHelper.setItem('dcaPlans', JSON.stringify(nextDcaPlans));
if (isPlainObject(cloudData.customSettings)) {
try {
const merged = { ...JSON.parse(localStorage.getItem('customSettings') || '{}'), ...cloudData.customSettings };
window.localStorage.setItem('customSettings', JSON.stringify(merged));
} catch { }
}
if (nextFunds.length) { if (nextFunds.length) {
const codes = Array.from(new Set(nextFunds.map((f) => f.code))); const codes = Array.from(new Set(nextFunds.map((f) => f.code)));
if (codes.length) await refreshAll(codes); if (codes.length) await refreshAll(codes);
@@ -3947,6 +3979,7 @@ export default function HomePage() {
if (row.holdingProfitValue == null) return; if (row.holdingProfitValue == null) return;
setPercentModes(prev => ({ ...prev, [row.code]: !prev[row.code] })); setPercentModes(prev => ({ ...prev, [row.code]: !prev[row.code] }));
}} }}
onCustomSettingsChange={triggerCustomSettingsSync}
/> />
</div> </div>
</div> </div>
@@ -3987,6 +4020,7 @@ export default function HomePage() {
if (row.holdingProfitValue == null) return; if (row.holdingProfitValue == null) return;
setPercentModes((prev) => ({ ...prev, [row.code]: !prev[row.code] })); setPercentModes((prev) => ({ ...prev, [row.code]: !prev[row.code] }));
}} }}
onCustomSettingsChange={triggerCustomSettingsSync}
/> />
)} )}
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">