From 26bb966f903c09e32a767b3a38cd2384df4bb982 Mon Sep 17 00:00:00 2001 From: hzm <934585316@qq.com> Date: Mon, 16 Mar 2026 21:04:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=9A=E7=BB=A9=E8=B5=B0=E5=8A=BF?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AF=B9=E6=AF=94=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/fund.js | 33 ++- app/components/FundCard.jsx | 15 +- app/components/FundTrendChart.jsx | 347 ++++++++++++++++++++++++------ app/components/PcFundTable.jsx | 2 +- app/page.jsx | 2 +- 5 files changed, 324 insertions(+), 75 deletions(-) diff --git a/app/api/fund.js b/app/api/fund.js index 218bf90..e58053b 100644 --- a/app/api/fund.js +++ b/app/api/fund.js @@ -770,14 +770,18 @@ export const fetchFundHistory = async (code, range = '1m') => { default: start = start.subtract(1, 'month'); } - // 业绩走势统一走 pingzhongdata.Data_netWorthTrend + // 业绩走势统一走 pingzhongdata.Data_netWorthTrend, + // 同时附带 Data_grandTotal(若存在,格式为 [{ name, data: [[ts, val], ...] }, ...]) try { const pz = await fetchFundPingzhongdata(code); const trend = pz?.Data_netWorthTrend; + const grandTotal = pz?.Data_grandTotal; + if (Array.isArray(trend) && trend.length) { const startMs = start.startOf('day').valueOf(); // end 可能是当日任意时刻,这里用 end-of-day 包含最后一天 const endMs = end.endOf('day').valueOf(); + const out = trend .filter((d) => d && typeof d.x === 'number' && d.x >= startMs && d.x <= endMs) .map((d) => { @@ -788,6 +792,33 @@ export const fetchFundHistory = async (code, range = '1m') => { }) .filter(Boolean); + // 解析 Data_grandTotal 为多条对比曲线,保存在数组属性 out.grandTotalSeries 上 + if (Array.isArray(grandTotal) && grandTotal.length) { + const grandTotalSeries = grandTotal + .map((series) => { + if (!series || !series.data || !Array.isArray(series.data)) return null; + const name = series.name || ''; + const points = series.data + .filter((item) => Array.isArray(item) && typeof item[0] === 'number') + .map(([ts, val]) => { + if (ts < startMs || ts > endMs) return null; + const numVal = Number(val); + if (!Number.isFinite(numVal)) return null; + const date = dayjs(ts).tz(TZ).format('YYYY-MM-DD'); + return { ts, date, value: numVal }; + }) + .filter(Boolean); + if (!points.length) return null; + return { name, points }; + }) + .filter(Boolean); + + if (grandTotalSeries.length) { + // 给数组挂一个属性,供前端图表组件读取 + out.grandTotalSeries = grandTotalSeries; + } + } + if (out.length) return out; } } catch (e) { diff --git a/app/components/FundCard.jsx b/app/components/FundCard.jsx index 75fd361..e5577a1 100644 --- a/app/components/FundCard.jsx +++ b/app/components/FundCard.jsx @@ -382,6 +382,15 @@ export default function FundCard({ {hasHoldings && ( +
+ 涨跌幅 / 占比 +
{f.holdings.map((h, idx) => (
@@ -409,7 +418,8 @@ export default function FundCard({ code={f.code} isExpanded onToggleExpand={() => onToggleTrendCollapse?.(f.code)} - transactions={transactions?.[f.code] || []} + // 未设置持仓金额时,不展示买入/卖出标记与标签 + transactions={profit ? (transactions?.[f.code] || []) : []} theme={theme} hideHeader /> @@ -480,7 +490,8 @@ export default function FundCard({ code={f.code} isExpanded={!collapsedTrends?.has(f.code)} onToggleExpand={() => onToggleTrendCollapse?.(f.code)} - transactions={transactions?.[f.code] || []} + // 未设置持仓金额时,不展示买入/卖出标记与标签 + transactions={profit ? (transactions?.[f.code] || []) : []} theme={theme} /> diff --git a/app/components/FundTrendChart.jsx b/app/components/FundTrendChart.jsx index 5a71e0d..ade4f09 100644 --- a/app/components/FundTrendChart.jsx +++ b/app/components/FundTrendChart.jsx @@ -62,6 +62,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans const [error, setError] = useState(null); const chartRef = useRef(null); const hoverTimeoutRef = useRef(null); + const [hiddenGrandSeries, setHiddenGrandSeries] = useState(() => new Set()); + const [activeIndex, setActiveIndex] = useState(null); const chartColors = useMemo(() => getChartThemeColors(theme), [theme]); @@ -119,10 +121,15 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans const lineColor = change >= 0 ? upColor : downColor; const primaryColor = chartColors.primary; + const percentageData = useMemo(() => { + if (!data.length) return []; + const firstValue = data[0].value ?? 1; + return data.map(d => ((d.value - firstValue) / firstValue) * 100); + }, [data]); + const chartData = useMemo(() => { - // Calculate percentage change based on the first data point - const firstValue = data.length > 0 ? data[0].value : 1; - const percentageData = data.map(d => ((d.value - firstValue) / firstValue) * 100); + // Data_grandTotal:在 fetchFundHistory 中解析为 data.grandTotalSeries 数组 + const grandTotalSeries = Array.isArray(data.grandTotalSeries) ? data.grandTotalSeries : []; // Map transaction dates to chart indices const dateToIndex = new Map(data.map((d, i) => [d.date, i])); @@ -143,12 +150,48 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans } }); + // 将 Data_grandTotal 的多条曲线按日期对齐到主 labels 上 + const labels = data.map(d => d.date); + // 对比线颜色:避免与主线红/绿(upColor/downColor)重复 + // 第三条对比线需要在亮/暗主题下都足够清晰,因此使用高对比的橙色强调 + const grandAccent3 = theme === 'light' ? '#f97316' : '#fb923c'; + const grandColors = [ + primaryColor, + chartColors.muted, + grandAccent3, + chartColors.text, + ]; + const grandDatasets = grandTotalSeries.map((series, idx) => { + const color = grandColors[idx % grandColors.length]; + const key = `${series.name || 'series'}_${idx}`; + const isHidden = hiddenGrandSeries.has(key); + const pointsByDate = new Map(series.points.map(p => [p.date, p.value])); + const seriesData = labels.map(date => { + const v = pointsByDate.get(date); + if (isHidden) return null; + return typeof v === 'number' ? v : null; + }); + return { + type: 'line', + label: series.name || '累计收益', + data: seriesData, + borderColor: color, + backgroundColor: color, + borderWidth: 1.5, + pointRadius: 0, + pointHoverRadius: 3, + fill: false, + tension: 0.2, + order: 2, + }; + }); + return { labels: data.map(d => d.date), datasets: [ { type: 'line', - label: '涨跌幅', + label: '净值涨跌幅', data: percentageData, borderColor: lineColor, backgroundColor: (context) => { @@ -165,9 +208,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans tension: 0.2, order: 2 }, + ...grandDatasets, { type: 'line', // Use line type with showLine: false to simulate scatter on Category scale label: '买入', + isTradePoint: true, data: buyPoints, borderColor: '#ffffff', borderWidth: 1, @@ -181,6 +226,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans { type: 'line', label: '卖出', + isTradePoint: true, data: sellPoints, borderColor: '#ffffff', borderWidth: 1, @@ -193,7 +239,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans } ] }; - }, [data, transactions, lineColor, primaryColor, upColor]); + }, [data, transactions, lineColor, primaryColor, upColor, chartColors, theme, hiddenGrandSeries, percentageData]); const options = useMemo(() => { const colors = getChartThemeColors(theme); @@ -265,9 +311,22 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans target.style.cursor = hasActive ? 'crosshair' : 'default'; } + // 记录当前激活的横轴索引,用于图示下方展示对应百分比 + if (Array.isArray(chartElement) && chartElement.length > 0) { + const idx = chartElement[0].index; + setActiveIndex(typeof idx === 'number' ? idx : null); + } else { + setActiveIndex(null); + } + // 仅用于桌面端 hover 改变光标,不在这里做 2 秒清除,避免移动端 hover 事件不稳定 }, - onClick: () => {} + onClick: (_event, elements) => { + if (Array.isArray(elements) && elements.length > 0) { + const idx = elements[0].index; + setActiveIndex(typeof idx === 'number' ? idx : null); + } + } }; }, [theme]); @@ -286,14 +345,14 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans afterEvent: (chart, args) => { const { event, replay } = args || {}; if (!event || replay) return; // 忽略动画重放 - + const type = event.type; if (type === 'mousemove' || type === 'click') { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } - + hoverTimeoutRef.current = setTimeout(() => { if (!chart) return; chart.setActiveElements([]); @@ -374,27 +433,35 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans activeElements = chart.getActiveElements(); } + const isBuyOrSellDataset = (ds) => + !!ds && (ds.isTradePoint === true || ds.label === '买入' || ds.label === '卖出'); + // 1. Draw default labels for first buy and sell points only when NOT focused/hovering - // Index 1 is Buy, Index 2 is Sell - if (!activeElements?.length && datasets[1] && datasets[1].data) { - const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined); - if (firstBuyIndex !== -1) { - let sellIndex = -1; - if (datasets[2] && datasets[2].data) { - sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined); - } - const isCollision = (firstBuyIndex === sellIndex); - drawPointLabel(1, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0); + // datasets 顺序是动态的:主线(0) + 对比线(若干) + 买入 + 卖出 + const buyDatasetIndex = datasets.findIndex(ds => ds?.label === '买入' || (ds?.isTradePoint === true && ds?.label === '买入')); + const sellDatasetIndex = datasets.findIndex(ds => ds?.label === '卖出' || (ds?.isTradePoint === true && ds?.label === '卖出')); + + if (!activeElements?.length && buyDatasetIndex !== -1 && datasets[buyDatasetIndex]?.data) { + const firstBuyIndex = datasets[buyDatasetIndex].data.findIndex(v => v !== null && v !== undefined); + if (firstBuyIndex !== -1) { + let sellIndex = -1; + if (sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) { + sellIndex = datasets[sellDatasetIndex].data.findIndex(v => v !== null && v !== undefined); } + const isCollision = (firstBuyIndex === sellIndex); + drawPointLabel(buyDatasetIndex, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0); + } } - if (!activeElements?.length && datasets[2] && datasets[2].data) { - const firstSellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined); - if (firstSellIndex !== -1) { - drawPointLabel(2, firstSellIndex, '卖出', '#f87171'); - } + + if (!activeElements?.length && sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) { + const firstSellIndex = datasets[sellDatasetIndex].data.findIndex(v => v !== null && v !== undefined); + if (firstSellIndex !== -1) { + drawPointLabel(sellDatasetIndex, firstSellIndex, '卖出', '#f87171'); + } } // 2. Handle active elements (hover crosshair) + // 始终保留十字线与 X/Y 坐标轴对应标签(坐标参照) if (activeElements && activeElements.length) { const activePoint = activeElements[0]; const x = activePoint.element.x; @@ -425,64 +492,62 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - // Draw Axis Labels based on the first point (main line) - const datasetIndex = activePoint.datasetIndex; - const index = activePoint.index; - + // Draw Axis Labels:始终使用主线(净值涨跌幅,索引 0)作为数值来源, + // 避免对比线在悬停时显示自己的数值标签 + const baseIndex = activePoint.index; const labels = chart.data.labels; + const mainDataset = datasets[0]; - if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) { - const dateStr = labels[index]; - const value = datasets[datasetIndex].data[index]; + if (labels && mainDataset && Array.isArray(mainDataset.data)) { + const dateStr = labels[baseIndex]; + const value = mainDataset.data[baseIndex]; - if (dateStr !== undefined && value !== undefined) { - // X axis label (date) with boundary clamping - const textWidth = ctx.measureText(dateStr).width + 8; - const chartLeft = chart.scales.x.left; - const chartRight = chart.scales.x.right; - let labelLeft = x - textWidth / 2; - if (labelLeft < chartLeft) labelLeft = chartLeft; - if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth; - const labelCenterX = labelLeft + textWidth / 2; - ctx.fillStyle = primaryColor; - ctx.fillRect(labelLeft, bottomY, textWidth, 16); - ctx.fillStyle = colors.crosshairText; - ctx.fillText(dateStr, labelCenterX, bottomY + 8); + if (dateStr !== undefined && value !== undefined) { + // X axis label (date) with boundary clamping + const textWidth = ctx.measureText(dateStr).width + 8; + const chartLeft = chart.scales.x.left; + const chartRight = chart.scales.x.right; + let labelLeft = x - textWidth / 2; + if (labelLeft < chartLeft) labelLeft = chartLeft; + if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth; + const labelCenterX = labelLeft + textWidth / 2; + ctx.fillStyle = primaryColor; + ctx.fillRect(labelLeft, bottomY, textWidth, 16); + ctx.fillStyle = colors.crosshairText; + ctx.fillText(dateStr, labelCenterX, bottomY + 8); - // Y axis label (value) - const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%'; - const valWidth = ctx.measureText(valueStr).width + 8; - ctx.fillStyle = primaryColor; - ctx.fillRect(leftX, y - 8, valWidth, 16); - ctx.fillStyle = colors.crosshairText; - ctx.textAlign = 'center'; - ctx.fillText(valueStr, leftX + valWidth / 2, y); - } + // Y axis label (value) — 始终基于主线百分比 + const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%'; + const valWidth = ctx.measureText(valueStr).width + 8; + ctx.fillStyle = primaryColor; + ctx.fillRect(leftX, y - 8, valWidth, 16); + ctx.fillStyle = colors.crosshairText; + ctx.textAlign = 'center'; + ctx.fillText(valueStr, leftX + valWidth / 2, y); + } } - // Check for collision between Buy (1) and Sell (2) in active elements - const activeBuy = activeElements.find(e => e.datasetIndex === 1); - const activeSell = activeElements.find(e => e.datasetIndex === 2); + // Check for collision between Buy and Sell in active elements + const activeBuy = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '买入'); + const activeSell = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '卖出'); const isCollision = activeBuy && activeSell && activeBuy.index === activeSell.index; - // Iterate through all active points to find transaction points and draw their labels + // Iterate through active points,仅为买入/卖出绘制标签 activeElements.forEach(element => { - const dsIndex = element.datasetIndex; - // Only for transaction datasets (index > 0) - if (dsIndex > 0 && datasets[dsIndex]) { - const label = datasets[dsIndex].label; - // Determine background color based on dataset index - // 1 = Buy (主题色), 2 = Sell (与折线图红色一致) - const bgColor = dsIndex === 1 ? primaryColor : colors.danger; + const dsIndex = element.datasetIndex; + const ds = datasets?.[dsIndex]; + if (!isBuyOrSellDataset(ds)) return; - // If collision, offset Buy label upwards - let yOffset = 0; - if (isCollision && dsIndex === 1) { - yOffset = -20; - } + const label = ds.label; + const bgColor = label === '买入' ? primaryColor : colors.danger; - drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset); - } + // 如果买入/卖出在同一天,买入标签上移避免遮挡 + let yOffset = 0; + if (isCollision && label === '买入') { + yOffset = -20; + } + + drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset); }); ctx.restore(); @@ -491,8 +556,150 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans }]; }, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair + const lastIndex = data.length > 0 ? data.length - 1 : null; + const currentIndex = activeIndex != null && activeIndex < data.length ? activeIndex : lastIndex; + const chartBlock = ( <> + {/* 顶部图示:说明不同颜色/标记代表的含义 */} +
+
+
+ + 净值涨跌幅 +
+ {currentIndex != null && percentageData[currentIndex] !== undefined && ( + + {percentageData[currentIndex].toFixed(2)}% + + )} +
+ {Array.isArray(data.grandTotalSeries) && + data.grandTotalSeries.map((series, idx) => { + // 与折线数据使用同一套对比色,且排除红/绿 + const legendAccent3 = theme === 'light' ? '#f97316' : '#fb923c'; + const legendColors = [ + primaryColor, + chartColors.muted, + legendAccent3, + chartColors.text, + ]; + const color = legendColors[idx % legendColors.length]; + const key = `${series.name || 'series'}_${idx}`; + const isHidden = hiddenGrandSeries.has(key); + let valueText = '--'; + if (!isHidden && currentIndex != null && data[currentIndex]) { + const targetDate = data[currentIndex].date; + const point = Array.isArray(series.points) + ? series.points.find(p => p.date === targetDate) + : null; + if (point && typeof point.value === 'number') { + valueText = `${point.value.toFixed(2)}%`; + } + } + return ( +
{ + e.stopPropagation(); + setHiddenGrandSeries(prev => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }} + > +
+ + + {series.name || '累计收益'} + + +
+ {!isHidden && valueText !== '--' && ( + + {valueText} + + )} +
+ ); + })} +
+
{loading && (
diff --git a/app/components/PcFundTable.jsx b/app/components/PcFundTable.jsx index ae36876..6160f6b 100644 --- a/app/components/PcFundTable.jsx +++ b/app/components/PcFundTable.jsx @@ -1212,7 +1212,7 @@ export default function PcFundTable({
{cardDialogRow && getFundCardProps ? ( diff --git a/app/page.jsx b/app/page.jsx index 3289009..64fc0c4 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -3719,7 +3719,7 @@ export default function HomePage() { initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} - className="search-dropdown glass" + className="search-dropdown glass scrollbar-y-styled" > {searchResults.length > 0 ? (