From e7661e7b3876706a6da4b41e2fb29bdcd40cd9a8 Mon Sep 17 00:00:00 2001 From: hzm <934585316@qq.com> Date: Sun, 1 Mar 2026 16:49:46 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E4=B8=AA=E6=80=A7=E5=8C=96?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BE=80=E4=BA=91=E7=AB=AF=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/FitText.jsx | 87 ++++++++++++++++++++++++++++++ app/components/MobileFundTable.jsx | 63 ++++++++++++++++------ app/components/PcFundTable.jsx | 4 ++ app/globals.css | 16 ++++-- app/page.jsx | 42 +++++++++++++-- 5 files changed, 187 insertions(+), 25 deletions(-) create mode 100644 app/components/FitText.jsx diff --git a/app/components/FitText.jsx b/app/components/FitText.jsx new file mode 100644 index 0000000..a9fdc01 --- /dev/null +++ b/app/components/FitText.jsx @@ -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 ( + + + {children} + + + ); +} diff --git a/app/components/MobileFundTable.jsx b/app/components/MobileFundTable.jsx index 24faffb..4f08002 100644 --- a/app/components/MobileFundTable.jsx +++ b/app/components/MobileFundTable.jsx @@ -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({ ), cell: (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({ {info.getValue() ?? '—'} - {date} + {date} ); }, - meta: { align: 'right', cellClassName: 'change-cell' }, + meta: { align: 'right', cellClassName: 'change-cell', width: 72 }, }, { accessorKey: 'estimateChangePercent', @@ -387,11 +391,11 @@ export default function MobileFundTable({ {info.getValue() ?? '—'} - {time} + {time} ); }, - 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 ( - - {hasProfit ? (info.getValue() ?? '') : ''} + + + {hasProfit ? (info.getValue() ?? '') : ''} + ); }, - 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 (
{ if (!hasTotal) return; e.stopPropagation?.(); onHoldingProfitClickRef.current?.(original); }} > - - {hasTotal ? (info.getValue() ?? '') : ''} + + + {hasTotal ? (info.getValue() ?? '') : ''} +
); }, - 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 (
-
+
{headerGroup && ( -
- {headerGroup.headers.map((header) => { +
+ {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 (
{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 (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
diff --git a/app/components/PcFundTable.jsx b/app/components/PcFundTable.jsx index 9ca3008..e571ce0 100644 --- a/app/components/PcFundTable.jsx +++ b/app/components/PcFundTable.jsx @@ -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 { } }; diff --git a/app/globals.css b/app/globals.css index f392e00..4069e5d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 */ diff --git a/app/page.jsx b/app/page.jsx index a573740..a4e4e0b 100644 --- a/app/page.jsx +++ b/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} />
@@ -3987,6 +4020,7 @@ export default function HomePage() { if (row.holdingProfitValue == null) return; setPercentModes((prev) => ({ ...prev, [row.code]: !prev[row.code] })); }} + onCustomSettingsChange={triggerCustomSettingsSync} /> )}