feat:个性化数据往云端同步
This commit is contained in:
87
app/components/FitText.jsx
Normal file
87
app/components/FitText.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import FitText from './FitText';
|
||||
import MobileSettingModal from './MobileSettingModal';
|
||||
import { ExitIcon, SettingsIcon, StarIcon } from './Icons';
|
||||
|
||||
@@ -100,6 +101,7 @@ export default function MobileFundTable({
|
||||
refreshing = false,
|
||||
sortBy = 'default',
|
||||
onReorder,
|
||||
onCustomSettingsChange,
|
||||
}) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -164,6 +166,7 @@ export default function MobileFundTable({
|
||||
? { ...parsed, mobileTableColumnOrder: nextOrder }
|
||||
: { mobileTableColumnOrder: nextOrder };
|
||||
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
|
||||
onCustomSettingsChange?.();
|
||||
} catch {}
|
||||
};
|
||||
const getStoredMobileColumnVisibility = () => {
|
||||
@@ -194,6 +197,7 @@ export default function MobileFundTable({
|
||||
? { ...parsed, mobileTableColumnVisibility: nextVisibility }
|
||||
: { mobileTableColumnVisibility: nextVisibility };
|
||||
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
|
||||
onCustomSettingsChange?.();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
@@ -352,7 +356,7 @@ export default function MobileFundTable({
|
||||
</div>
|
||||
),
|
||||
cell: (info) => <MobileFundNameCell info={info} />,
|
||||
meta: { align: 'left', cellClassName: 'name-cell' },
|
||||
meta: { align: 'left', cellClassName: 'name-cell', width: 140 },
|
||||
},
|
||||
{
|
||||
accessorKey: 'yesterdayChangePercent',
|
||||
@@ -367,11 +371,11 @@ export default function MobileFundTable({
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '11px' }}>{date}</span>
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{date}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'change-cell' },
|
||||
meta: { align: 'right', cellClassName: 'change-cell', width: 72 },
|
||||
},
|
||||
{
|
||||
accessorKey: 'estimateChangePercent',
|
||||
@@ -387,11 +391,11 @@ export default function MobileFundTable({
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '11px' }}>{time}</span>
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{time}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'est-change-cell' },
|
||||
meta: { align: 'right', cellClassName: 'est-change-cell', width: 80 },
|
||||
},
|
||||
{
|
||||
accessorKey: 'todayProfit',
|
||||
@@ -402,12 +406,14 @@ export default function MobileFundTable({
|
||||
const hasProfit = value != null;
|
||||
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||
return (
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{hasProfit ? (info.getValue() ?? '') : ''}
|
||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{hasProfit ? (info.getValue() ?? '') : ''}
|
||||
</FitText>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'profit-cell' },
|
||||
meta: { align: 'right', cellClassName: 'profit-cell', width: 80 },
|
||||
},
|
||||
{
|
||||
accessorKey: 'holdingProfit',
|
||||
@@ -420,20 +426,22 @@ export default function MobileFundTable({
|
||||
return (
|
||||
<div
|
||||
title="点击切换金额/百分比"
|
||||
style={{ cursor: hasTotal ? 'pointer' : 'default' }}
|
||||
style={{ cursor: hasTotal ? 'pointer' : 'default', width: '100%' }}
|
||||
onClick={(e) => {
|
||||
if (!hasTotal) return;
|
||||
e.stopPropagation?.();
|
||||
onHoldingProfitClickRef.current?.(original);
|
||||
}}
|
||||
>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{hasTotal ? (info.getValue() ?? '') : ''}
|
||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{hasTotal ? (info.getValue() ?? '') : ''}
|
||||
</FitText>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'holding-cell' },
|
||||
meta: { align: 'right', cellClassName: 'holding-cell', width: 80 },
|
||||
},
|
||||
],
|
||||
[currentTab, favorites, refreshing]
|
||||
@@ -474,6 +482,18 @@ export default function MobileFundTable({
|
||||
|
||||
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) => {
|
||||
if (columnId === 'fundName') return isHeader ? 'table-header-cell-pin-left' : 'table-cell-pin-left';
|
||||
return '';
|
||||
@@ -487,17 +507,25 @@ export default function MobileFundTable({
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="table-header-row mobile-fund-table-header">
|
||||
{headerGroup.headers.map((header) => {
|
||||
<div
|
||||
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 pinClass = getPinClass(columnId, true);
|
||||
const alignClass = getAlignClass(columnId);
|
||||
const isLastColumn = headerIndex === headerGroup.headers.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className={`table-header-cell ${alignClass} ${pinClass}`}
|
||||
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -536,18 +564,21 @@ export default function MobileFundTable({
|
||||
background: 'var(--bg)',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
|
||||
}}
|
||||
{...(sortBy === 'default' ? listeners : {})}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||
const columnId = cell.column.id;
|
||||
const pinClass = getPinClass(columnId, false);
|
||||
const alignClass = getAlignClass(columnId);
|
||||
const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
|
||||
const isLastColumn = cellIndex === row.getVisibleCells().length - 1;
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
|
||||
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
|
||||
@@ -126,6 +126,7 @@ export default function PcFundTable({
|
||||
refreshing = false,
|
||||
sortBy = 'default',
|
||||
onReorder,
|
||||
onCustomSettingsChange,
|
||||
}) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -183,6 +184,7 @@ export default function PcFundTable({
|
||||
? { ...parsed, pcTableColumns: nextSizing }
|
||||
: { pcTableColumns: nextSizing };
|
||||
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
|
||||
onCustomSettingsChange?.();
|
||||
} catch { }
|
||||
};
|
||||
|
||||
@@ -212,6 +214,7 @@ export default function PcFundTable({
|
||||
? { ...parsed, pcTableColumnOrder: nextOrder }
|
||||
: { pcTableColumnOrder: nextOrder };
|
||||
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
|
||||
onCustomSettingsChange?.();
|
||||
} catch { }
|
||||
};
|
||||
|
||||
@@ -246,6 +249,7 @@ export default function PcFundTable({
|
||||
? { ...parsed, pcTableColumnVisibility: nextVisibility }
|
||||
: { pcTableColumnVisibility: nextVisibility };
|
||||
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
|
||||
onCustomSettingsChange?.();
|
||||
} catch { }
|
||||
};
|
||||
|
||||
|
||||
@@ -1428,24 +1428,27 @@ input[type="number"] {
|
||||
}
|
||||
|
||||
.mobile-fund-table-scroll {
|
||||
min-width: 520px;
|
||||
/* min-width 由 MobileFundTable 根据 columns meta.width 动态设置 */
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-header-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr 1fr 1.2fr 1.2fr;
|
||||
/* grid-template-columns 由 MobileFundTable 根据当前列顺序动态设置 */
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-header-row .table-header-cell {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-row {
|
||||
grid-template-columns: 140px 1fr 1fr 1.2fr 1.2fr;
|
||||
grid-template-areas: unset;
|
||||
gap: 12px;
|
||||
padding-top: 0 !important;
|
||||
@@ -1468,6 +1471,9 @@ input[type="number"] {
|
||||
.mobile-fund-table .table-row .table-cell {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 基金名称列固定左侧:右侧阴影突出固定列,覆盖行 border-bottom */
|
||||
|
||||
42
app/page.jsx
42
app/page.jsx
@@ -1502,7 +1502,7 @@ export default function HomePage() {
|
||||
|
||||
const storageHelper = useMemo(() => {
|
||||
// 仅以下 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) => {
|
||||
if (keys.has(key)) {
|
||||
// 标记为脏数据
|
||||
@@ -1549,7 +1549,7 @@ export default function HomePage() {
|
||||
|
||||
useEffect(() => {
|
||||
// 仅以下 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) => {
|
||||
if (!e.key) return;
|
||||
if (e.key === 'localUpdatedAt') {
|
||||
@@ -1570,6 +1570,18 @@ export default function HomePage() {
|
||||
};
|
||||
}, [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) => {
|
||||
if (mode !== 'card' && mode !== 'list') return;
|
||||
setViewMode(mode);
|
||||
@@ -2561,6 +2573,7 @@ export default function HomePage() {
|
||||
const raw = window.localStorage.getItem('customSettings');
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
window.localStorage.setItem('customSettings', JSON.stringify({ ...parsed, pcContainerWidth: w }));
|
||||
triggerCustomSettingsSync();
|
||||
} catch { }
|
||||
setSettingsOpen(false);
|
||||
};
|
||||
@@ -2571,6 +2584,7 @@ export default function HomePage() {
|
||||
const raw = window.localStorage.getItem('customSettings');
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
window.localStorage.setItem('customSettings', JSON.stringify({ ...parsed, pcContainerWidth: 1200 }));
|
||||
triggerCustomSettingsSync();
|
||||
} catch { }
|
||||
};
|
||||
|
||||
@@ -2715,6 +2729,7 @@ export default function HomePage() {
|
||||
});
|
||||
|
||||
const viewMode = payload.viewMode === 'list' ? 'list' : 'card';
|
||||
const customSettings = isPlainObject(payload.customSettings) ? payload.customSettings : {};
|
||||
|
||||
return JSON.stringify({
|
||||
funds: uniqueFundCodes,
|
||||
@@ -2727,7 +2742,8 @@ export default function HomePage() {
|
||||
pendingTrades,
|
||||
transactions,
|
||||
dcaPlans,
|
||||
viewMode
|
||||
viewMode,
|
||||
customSettings
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2768,6 +2784,13 @@ export default function HomePage() {
|
||||
if (!keys || keys.has('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),进行完整的数据清洗和验证逻辑
|
||||
if (!keys) {
|
||||
@@ -2837,7 +2860,8 @@ export default function HomePage() {
|
||||
pendingTrades: all.pendingTrades,
|
||||
transactions: all.transactions,
|
||||
dcaPlans: cleanedDcaPlans,
|
||||
viewMode: all.viewMode
|
||||
viewMode: all.viewMode,
|
||||
customSettings: isPlainObject(all.customSettings) ? all.customSettings : {}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2858,6 +2882,7 @@ export default function HomePage() {
|
||||
transactions: {},
|
||||
dcaPlans: {},
|
||||
viewMode: 'card',
|
||||
customSettings: {},
|
||||
exportedAt: nowInTz().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -2919,6 +2944,13 @@ export default function HomePage() {
|
||||
setDcaPlans(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) {
|
||||
const codes = Array.from(new Set(nextFunds.map((f) => f.code)));
|
||||
if (codes.length) await refreshAll(codes);
|
||||
@@ -3947,6 +3979,7 @@ export default function HomePage() {
|
||||
if (row.holdingProfitValue == null) return;
|
||||
setPercentModes(prev => ({ ...prev, [row.code]: !prev[row.code] }));
|
||||
}}
|
||||
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3987,6 +4020,7 @@ export default function HomePage() {
|
||||
if (row.holdingProfitValue == null) return;
|
||||
setPercentModes((prev) => ({ ...prev, [row.code]: !prev[row.code] }));
|
||||
}}
|
||||
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout">
|
||||
|
||||
Reference in New Issue
Block a user